From be3b727806208cb5dd4eef9d8f463e8e4095ce73 Mon Sep 17 00:00:00 2001 From: Ian Hodge Date: Thu, 16 Jul 2026 13:32:00 -0400 Subject: [PATCH] Add AskUserQuestion support to the TUI Co-Authored-By: Oz --- app/src/ai/blocklist/action_model.rs | 14 +- .../inline_action/ask_user_question_view.rs | 530 +-------------- .../ask_user_question_view_tests.rs | 483 +------------ app/src/ai/blocklist/mod.rs | 6 +- app/src/tui_export.rs | 17 +- .../ai/src/agent/ask_user_question_session.rs | 517 ++++++++++++++ .../agent/ask_user_question_session_tests.rs | 447 ++++++++++++ crates/ai/src/agent/mod.rs | 6 +- crates/warp_tui/src/agent_block.rs | 50 +- crates/warp_tui/src/agent_block_tests.rs | 105 +++ crates/warp_tui/src/input/view_tests.rs | 65 +- crates/warp_tui/src/keybindings.rs | 3 +- crates/warp_tui/src/lib.rs | 1 + crates/warp_tui/src/option_selector.rs | 138 +++- crates/warp_tui/src/option_selector_tests.rs | 35 +- crates/warp_tui/src/orchestration_block.rs | 13 +- .../warp_tui/src/orchestration_block_tests.rs | 1 + crates/warp_tui/src/terminal_session_view.rs | 29 +- crates/warp_tui/src/tui_ask_question_view.rs | 634 ++++++++++++++++++ .../src/tui_ask_question_view_tests.rs | 343 ++++++++++ crates/warp_tui/src/tui_builder.rs | 10 + 21 files changed, 2397 insertions(+), 1050 deletions(-) create mode 100644 crates/ai/src/agent/ask_user_question_session.rs create mode 100644 crates/ai/src/agent/ask_user_question_session_tests.rs create mode 100644 crates/warp_tui/src/tui_ask_question_view.rs create mode 100644 crates/warp_tui/src/tui_ask_question_view_tests.rs diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 0a7062c2b7f..328e5d06f2e 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -30,12 +30,13 @@ pub(crate) use execute::{ #[cfg(test)] pub(crate) use execute::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode}; pub use execute::{ - read_local_file_context, EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, - EditResolvedEvent, EditStats, NewConversationDecision, PromptSuggestionExecutor, - ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind, - RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor, - StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, + read_local_file_context, AskUserQuestionExecutor, EditAcceptAndContinueClickedEvent, + EditAcceptClickedEvent, EditResolvedEvent, EditStats, NewConversationDecision, + PromptSuggestionExecutor, ReadFileContextResult, RequestFileEditsExecutor, + RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent, RunAgentsExecutor, + RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, + ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, + StartAgentRequestId, }; use futures::future::{join_all, BoxFuture}; use itertools::Itertools; @@ -43,7 +44,6 @@ use parking_lot::FairMutex; use preprocess::{PendingPreprocessedActions, PreprocessId}; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; -use self::execute::ask_user_question::AskUserQuestionExecutor; use self::execute::search_codebase::SearchCodebaseExecutor; use self::execute::{ BlocklistAIActionExecutor, BlocklistAIActionExecutorEvent, NotExecutedReason, diff --git a/app/src/ai/blocklist/inline_action/ask_user_question_view.rs b/app/src/ai/blocklist/inline_action/ask_user_question_view.rs index 20ca0d4a002..5bb41ae36b5 100644 --- a/app/src/ai/blocklist/inline_action/ask_user_question_view.rs +++ b/app/src/ai/blocklist/inline_action/ask_user_question_view.rs @@ -1,9 +1,12 @@ -use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType}; use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult}; +use ai::agent::{ + AskUserQuestionAction, AskUserQuestionCurrent, AskUserQuestionEffect, AskUserQuestionPhase, + AskUserQuestionSession, QuestionDraft, +}; use itertools::Itertools; use warp_core::ui::theme::color::internal_colors; use warp_core::ui::theme::WarpTheme; @@ -85,10 +88,6 @@ fn ask_user_question_header_height(appearance: &Appearance, app: &AppContext) -> + (2. * INLINE_ACTION_HEADER_VERTICAL_PADDING) } -fn ask_user_question_auto_advance_enabled(is_multiselect: bool, is_last_question: bool) -> bool { - is_last_question || !is_multiselect -} - pub fn init(app: &mut AppContext) { use warpui::keymap::macros::*; @@ -141,150 +140,6 @@ pub enum AskUserQuestionViewEvent { SpeedbumpPermissionChanged(AskUserQuestionPermission), } -#[derive(Clone, Debug, Default, Eq, PartialEq)] -/// In-progress answer data for a single question while the user is editing. -/// Tracks selected choices plus optional free-text "Other" input. -pub(crate) struct QuestionDraft { - pub selected_option_indices: HashSet, - pub other_text: Option, - pub is_other_input_active: bool, -} - -impl QuestionDraft { - fn has_answer(&self) -> bool { - !self.selected_option_indices.is_empty() - || self - .other_text - .as_deref() - .is_some_and(|text| !text.is_empty()) - } - fn is_empty(&self) -> bool { - !self.has_answer() && !self.is_other_input_active - } -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -/// Per-question draft slot used by the questionnaire state machine. -/// `Unanswered` is intentionally distinct from an empty `Answered` draft. -enum QuestionDraftState { - #[default] - Unanswered, - Answered(QuestionDraft), -} - -/// Snapshot of the currently visible question and its optional draft. -#[derive(Clone, Copy)] -struct AskUserQuestionCurrent<'a> { - question: &'a AskUserQuestionItem, - draft: Option<&'a QuestionDraft>, -} - -/// Rendering phase for this questionnaire block. -#[derive(Clone, Copy)] -enum AskUserQuestionPhase<'a> { - Editing, - Completed { - answers: &'a [AskUserQuestionAnswerItem], - }, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -/// Editing-phase state for the questionnaire. -/// Holds the active question index and one draft slot per question. -struct AskUserQuestionEditingState { - current_question_index: usize, - drafts: Vec, -} - -impl AskUserQuestionEditingState { - // Helpers for reading/updating the active question cursor and per-question draft slots. - fn new(draft_count: usize) -> Self { - Self { - current_question_index: 0, - drafts: vec![QuestionDraftState::Unanswered; draft_count], - } - } - - fn current_question_index(&self) -> usize { - self.current_question_index - } - - fn current_draft(&self) -> Option<&QuestionDraft> { - self.draft_for_question(self.current_question_index) - } - - fn draft_for_question(&self, index: usize) -> Option<&QuestionDraft> { - let QuestionDraftState::Answered(draft) = self.drafts.get(index)? else { - return None; - }; - Some(draft) - } - - fn is_last_question(&self, question_count: usize) -> bool { - self.current_question_index + 1 >= question_count - } - - fn update_current_draft(&mut self, update: impl FnOnce(&mut QuestionDraft)) { - let Some(slot) = self.drafts.get_mut(self.current_question_index) else { - return; - }; - - // Store unanswered questions as a distinct state instead of an empty draft so later logic - // can tell the difference between "no answer yet" and "there is answer state to render". - let mut draft = match std::mem::take(slot) { - QuestionDraftState::Unanswered => QuestionDraft::default(), - QuestionDraftState::Answered(draft) => draft, - }; - update(&mut draft); - *slot = if draft.is_empty() { - QuestionDraftState::Unanswered - } else { - QuestionDraftState::Answered(draft) - }; - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -/// Lifecycle state for the questionnaire flow. -/// `Editing` is mutable local draft state; `Completed` is the frozen submitted summary. -enum AskUserQuestionState { - Editing(AskUserQuestionEditingState), - Completed { - answers: Vec, - }, -} - -/// State-machine inputs derived from UI interactions. -#[derive(Clone, Debug, Eq, PartialEq)] -enum AskUserQuestionAction { - ToggleOption { - option_index: usize, - }, - OpenOtherInput, - SaveOtherText { - text: Option, - }, - NavigatePrev, - NavigateNext, - PressEnter { - highlighted_index: Option, - active_other_text: Option, - }, - Confirm, - SkipAll, -} - -/// State-machine outputs that tell the view which follow-up UI work to do. -#[derive(Clone, Debug, Eq, PartialEq)] -enum AskUserQuestionEffect { - Noop, - RefreshCurrent, - FocusOtherInput, - ShowQuestion, - ScheduleAutoAdvance, - Submit(Vec), -} - /// Derived render state for controls that depend on the active question/draft. #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AskUserQuestionViewState { @@ -320,381 +175,6 @@ struct AskUserQuestionCompletionState { status_icon: warpui::elements::Icon, } -/// Local questionnaire state machine used by the view. -/// UI events are translated into actions here, which update internal state and return effects for -/// follow-up view work (focus, refresh, navigation, submit). -struct AskUserQuestionSession { - questions: Vec, - state: AskUserQuestionState, -} - -/// Owns questionnaire prompts and applies state transitions independently of persisted action -/// status, returning effects for the view to execute. -impl AskUserQuestionSession { - fn new(mut questions: Vec) -> Self { - // Put multi-select questions before single-select so the last question - // can auto-submit after a single option toggle. - questions.sort_by_key(|q| !q.is_multiselect()); - Self { - state: AskUserQuestionState::Editing(AskUserQuestionEditingState::new(questions.len())), - questions, - } - } - - fn phase(&self) -> AskUserQuestionPhase<'_> { - match &self.state { - AskUserQuestionState::Editing(_) => AskUserQuestionPhase::Editing, - AskUserQuestionState::Completed { answers } => { - AskUserQuestionPhase::Completed { answers } - } - } - } - - fn is_editing(&self) -> bool { - matches!(self.state, AskUserQuestionState::Editing(_)) - } - - fn questions(&self) -> &[AskUserQuestionItem] { - &self.questions - } - - fn question_count(&self) -> usize { - self.questions.len() - } - - fn has_multiple_questions(&self) -> bool { - self.question_count() > 1 - } - - fn current(&self) -> Option> { - let AskUserQuestionState::Editing(editing) = &self.state else { - return None; - }; - - Some(AskUserQuestionCurrent { - question: self.questions.get(editing.current_question_index())?, - draft: editing.current_draft(), - }) - } - - /// Saved draft for the question at `index` (None when unanswered or not editing). Lets the - /// off-screen measurement copies reflect each question's real answer state, so a long saved - /// "Other..." answer is accounted for in the reserved height instead of resizing the card when - /// the user navigates back to that question. - fn draft_for_question(&self, index: usize) -> Option<&QuestionDraft> { - let AskUserQuestionState::Editing(editing) = &self.state else { - return None; - }; - editing.draft_for_question(index) - } - - fn current_question_index(&self) -> usize { - match &self.state { - AskUserQuestionState::Editing(editing) => editing.current_question_index(), - AskUserQuestionState::Completed { .. } => 0, - } - } - - fn is_last_question(&self) -> bool { - match &self.state { - AskUserQuestionState::Editing(editing) => { - editing.is_last_question(self.questions.len()) - } - AskUserQuestionState::Completed { .. } => false, - } - } - - // Centralize all state transitions so the view layer only maps UI events to actions and then - // applies the returned effect. - fn apply(&mut self, action: AskUserQuestionAction) -> AskUserQuestionEffect { - match action { - AskUserQuestionAction::ToggleOption { option_index } => { - self.toggle_option(option_index) - } - AskUserQuestionAction::OpenOtherInput => self.open_other_input(), - AskUserQuestionAction::SaveOtherText { text } => self.save_other_text(text), - AskUserQuestionAction::NavigatePrev => self.navigate_prev(), - AskUserQuestionAction::NavigateNext => self.navigate_next(), - AskUserQuestionAction::PressEnter { - highlighted_index, - active_other_text, - } => self.press_enter(highlighted_index, active_other_text), - AskUserQuestionAction::Confirm => self.confirm(), - AskUserQuestionAction::SkipAll => self.skip_all(), - } - } - - fn editing_state_mut(&mut self) -> Option<&mut AskUserQuestionEditingState> { - let AskUserQuestionState::Editing(editing) = &mut self.state else { - return None; - }; - Some(editing) - } - - fn toggle_option(&mut self, option_index: usize) -> AskUserQuestionEffect { - let Some((is_multi_select, auto_advance_enabled)) = self.current().map(|current| { - let is_multi_select = current.question.is_multiselect(); - ( - is_multi_select, - ask_user_question_auto_advance_enabled(is_multi_select, self.is_last_question()), - ) - }) else { - return AskUserQuestionEffect::Noop; - }; - - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - - let mut should_auto_advance_after_toggle = false; - editing.update_current_draft(|draft| { - if is_multi_select { - // Multiselect behaves like a checklist: toggling one option should not affect any - // of the other selected options, and only the last question is allowed to auto-advance. - if !draft.selected_option_indices.insert(option_index) { - draft.selected_option_indices.remove(&option_index); - } - should_auto_advance_after_toggle = - auto_advance_enabled && !draft.selected_option_indices.is_empty(); - return; - } - // Single-select behaves like a radio group, except clicking the selected option again - // clears the answer entirely. - if draft.selected_option_indices.contains(&option_index) { - draft.selected_option_indices.clear(); - draft.other_text = None; - draft.is_other_input_active = false; - return; - } - - draft.selected_option_indices.clear(); - draft.selected_option_indices.insert(option_index); - draft.other_text = None; - draft.is_other_input_active = false; - should_auto_advance_after_toggle = auto_advance_enabled; - }); - - if should_auto_advance_after_toggle { - AskUserQuestionEffect::ScheduleAutoAdvance - } else { - AskUserQuestionEffect::RefreshCurrent - } - } - - fn open_other_input(&mut self) -> AskUserQuestionEffect { - let Some(is_multi_select) = self - .current() - .map(|current| current.question.is_multiselect()) - else { - return AskUserQuestionEffect::Noop; - }; - - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - - editing.update_current_draft(|draft| { - if !is_multi_select { - draft.selected_option_indices.clear(); - } - draft.is_other_input_active = true; - }); - AskUserQuestionEffect::FocusOtherInput - } - - fn save_other_text(&mut self, text: Option) -> AskUserQuestionEffect { - let Some(auto_advance_enabled) = self.current().map(|current| { - ask_user_question_auto_advance_enabled( - current.question.is_multiselect(), - self.is_last_question(), - ) - }) else { - return AskUserQuestionEffect::Noop; - }; - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - - editing.update_current_draft(|draft| { - draft.other_text = text; - draft.is_other_input_active = false; - }); - if editing - .current_draft() - .is_some_and(|draft| draft.other_text.is_some()) - { - if auto_advance_enabled { - AskUserQuestionEffect::ScheduleAutoAdvance - } else { - AskUserQuestionEffect::RefreshCurrent - } - } else { - AskUserQuestionEffect::RefreshCurrent - } - } - - fn navigate_prev(&mut self) -> AskUserQuestionEffect { - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - if editing.current_question_index == 0 { - return AskUserQuestionEffect::Noop; - } - - editing.current_question_index -= 1; - AskUserQuestionEffect::ShowQuestion - } - - fn navigate_next(&mut self) -> AskUserQuestionEffect { - let question_count = self.questions.len(); - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - if editing.is_last_question(question_count) { - return AskUserQuestionEffect::Noop; - } - - editing.current_question_index += 1; - AskUserQuestionEffect::ShowQuestion - } - - fn press_enter( - &mut self, - highlighted_index: Option, - active_other_text: Option, - ) -> AskUserQuestionEffect { - let Some((supports_other, option_count)) = self.current().map(|current| { - ( - current.question.supports_other(), - current - .question - .multiple_choice_options() - .map_or(0, |options| options.len()), - ) - }) else { - return AskUserQuestionEffect::Noop; - }; - - if supports_other && highlighted_index == Some(option_count) { - return self.open_other_input(); - } - - if let Some(option_index) = highlighted_index.filter(|index| *index < option_count) { - let _ = self.toggle_option(option_index); - return self.enter_submit_effect(); - } - - if self - .current() - .and_then(|current| current.draft) - .is_some_and(|draft| draft.is_other_input_active) - { - let _ = self.save_other_text(active_other_text); - } - - self.enter_submit_effect() - } - - fn enter_submit_effect(&mut self) -> AskUserQuestionEffect { - if self - .current() - .and_then(|current| current.draft) - .is_some_and(QuestionDraft::has_answer) - { - AskUserQuestionEffect::ScheduleAutoAdvance - } else { - self.confirm() - } - } - - fn confirm(&mut self) -> AskUserQuestionEffect { - let question_count = self.questions.len(); - let drafts = { - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - if !editing.is_last_question(question_count) { - editing.current_question_index += 1; - return AskUserQuestionEffect::ShowQuestion; - } - - editing.drafts.clone() - }; - let answers = Self::build_answers(&self.questions, &drafts); - - self.state = AskUserQuestionState::Completed { - answers: answers.clone(), - }; - AskUserQuestionEffect::Submit(answers) - } - - fn skip_all(&mut self) -> AskUserQuestionEffect { - let drafts = { - let Some(editing) = self.editing_state_mut() else { - return AskUserQuestionEffect::Noop; - }; - for draft in &mut editing.drafts { - *draft = QuestionDraftState::Unanswered; - } - - editing.drafts.clone() - }; - let answers = Self::build_answers(&self.questions, &drafts); - - self.state = AskUserQuestionState::Completed { - answers: answers.clone(), - }; - AskUserQuestionEffect::Submit(answers) - } - - fn build_answers( - questions: &[AskUserQuestionItem], - drafts: &[QuestionDraftState], - ) -> Vec { - questions - .iter() - .enumerate() - .map(|(index, question)| Self::build_answer(question, drafts.get(index))) - .collect_vec() - } - - fn build_answer( - question: &AskUserQuestionItem, - draft: Option<&QuestionDraftState>, - ) -> AskUserQuestionAnswerItem { - // The executor expects one answer entry per question, so unanswered drafts and drafts that - // collapse back to "no actual content" are both normalized to Skipped here. - let Some(QuestionDraftState::Answered(draft)) = draft else { - return AskUserQuestionAnswerItem::Skipped { - question_id: question.question_id.clone(), - }; - }; - - let selected_options = match &question.question_type { - AskUserQuestionType::MultipleChoice { options, .. } => draft - .selected_option_indices - .iter() - .copied() - .sorted_unstable() - .filter_map(|index| options.get(index).map(|option| option.label.clone())) - .collect_vec(), - }; - let other_text = draft.other_text.clone().unwrap_or_default(); - - if selected_options.is_empty() && other_text.is_empty() { - AskUserQuestionAnswerItem::Skipped { - question_id: question.question_id.clone(), - } - } else { - AskUserQuestionAnswerItem::Answered { - question_id: question.question_id.clone(), - selected_options, - other_text, - } - } - } -} - /// Stateful inline-action view that renders questionnaire UI and coordinates with the action model. pub(crate) struct AskUserQuestionView { action_model: ModelHandle, @@ -1795,7 +1275,7 @@ impl TypedActionView for AskUserQuestionView { .buttons .read(ctx, |buttons, _| buttons.selected_button_index()); let active_other_text = self.read_active_other_text(ctx); - let effect = self.session.apply(AskUserQuestionAction::PressEnter { + let effect = self.session.apply(AskUserQuestionAction::SubmitAnswer { highlighted_index, active_other_text, }); diff --git a/app/src/ai/blocklist/inline_action/ask_user_question_view_tests.rs b/app/src/ai/blocklist/inline_action/ask_user_question_view_tests.rs index 212b57c5de1..c32b22f0518 100644 --- a/app/src/ai/blocklist/inline_action/ask_user_question_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/ask_user_question_view_tests.rs @@ -1,495 +1,48 @@ use ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType}; -use ai::agent::action_result::AskUserQuestionAnswerItem; +use ai::agent::{AskUserQuestionAction, AskUserQuestionSession}; -use super::{ - ask_user_question_view_state, AskUserQuestionAction, AskUserQuestionEffect, - AskUserQuestionPhase, AskUserQuestionSession, AskUserQuestionViewState, -}; +use super::{ask_user_question_view_state, AskUserQuestionViewState}; -fn build_question( - question_id: &str, - question: &str, - is_multiselect: bool, - supports_other: bool, - options: &[&str], -) -> AskUserQuestionItem { +fn build_question(question_id: &str, supports_other: bool) -> AskUserQuestionItem { AskUserQuestionItem { question_id: question_id.to_string(), - question: question.to_string(), + question: "Question".to_string(), question_type: AskUserQuestionType::MultipleChoice { - is_multiselect, - options: options - .iter() - .map(|label| AskUserQuestionOption { - label: (*label).to_string(), - recommended: false, - }) - .collect(), + is_multiselect: false, + options: vec![AskUserQuestionOption { + label: "Stable".to_string(), + recommended: false, + }], supports_other, }, } } -fn build_session(questions: Vec) -> AskUserQuestionSession { - AskUserQuestionSession::new(questions) -} - -fn view_state_for(session: &AskUserQuestionSession) -> AskUserQuestionViewState { - ask_user_question_view_state(session.current()) -} - -fn current_draft(session: &AskUserQuestionSession) -> Option<&super::QuestionDraft> { - session.current().and_then(|current| current.draft) -} - -#[test] -fn enter_on_other_row_focuses_the_other_input() { - let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: Some(1), - active_other_text: None, - }), - AskUserQuestionEffect::FocusOtherInput - ); -} - -#[test] -fn enter_without_an_answer_submits_the_last_question_immediately() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - false, - true, - &["Stable", "Nightly"], - )]); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: None, - active_other_text: None, - }), - AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped { - question_id: "q1".to_string(), - }]) - ); -} - -#[test] -fn enter_after_a_selected_answer_schedules_auto_advance() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - false, - false, - &["Stable", "Nightly"], - )]); - - assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: None, - active_other_text: None, - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); -} - -#[test] -fn enter_with_blank_other_input_submits_the_last_question_immediately() { - let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); - - assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: None, - active_other_text: None, - }), - AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped { - question_id: "q1".to_string(), - }]) - ); -} - -#[test] -fn enter_with_active_other_text_schedules_auto_advance() { - let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); - - assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: None, - active_other_text: Some("nightly".to_string()), - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert_eq!( - current_draft(&session).and_then(|draft| draft.other_text.as_deref()), - Some("nightly") - ); -} - -#[test] -fn enter_on_single_select_option_toggles_it_and_schedules_auto_advance() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - false, - true, - &["Stable", "Nightly"], - )]); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: Some(1), - active_other_text: None, - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); -} - -#[test] -fn enter_on_non_last_multi_select_option_toggles_it_and_schedules_auto_advance() { - let mut session = build_session(vec![ - build_question("q1", "First", true, true, &["Stable", "Nightly"]), - build_question("q2", "Second", false, false, &["CLI"]), - ]); - - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: Some(1), - active_other_text: None, - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); -} - #[test] -fn enter_on_answered_non_last_multi_select_option_keeps_existing_selection_and_advances() { - let mut session = build_session(vec![ - build_question("q1", "First", true, true, &["Stable", "Nightly"]), - build_question("q2", "Second", false, false, &["CLI"]), +fn view_state_shows_other_input_only_for_the_current_question() { + let mut session = AskUserQuestionSession::new(vec![ + build_question("q1", true), + build_question("q2", false), ]); assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), - AskUserQuestionEffect::RefreshCurrent - ); - assert_eq!( - session.apply(AskUserQuestionAction::PressEnter { - highlighted_index: Some(1), - active_other_text: None, - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert!(current_draft(&session).is_some_and(|draft| { - draft.selected_option_indices.contains(&0) && draft.selected_option_indices.contains(&1) - })); -} - -#[test] -fn single_select_non_last_toggle_schedules_auto_advance() { - let mut session = build_session(vec![ - build_question("q1", "First", false, false, &["Rust", "Go"]), - build_question("q2", "Second", false, false, &["CLI", "GUI"]), - ]); - - let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }); - - assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance); - assert_eq!(session.current_question_index(), 0); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); - assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); -} - -#[test] -fn multi_select_non_last_toggle_does_not_auto_advance() { - let mut session = build_session(vec![ - build_question("q1", "First", true, false, &["Rust", "Go"]), - build_question("q2", "Second", false, false, &["CLI", "GUI"]), - ]); - - let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }); - - assert_eq!(effect, AskUserQuestionEffect::RefreshCurrent); - assert_eq!(session.current_question_index(), 0); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); - assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); -} - -#[test] -fn last_multi_select_toggle_schedules_auto_advance() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - true, - false, - &["Rust", "Go"], - )]); - - let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }); - - assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance); - assert_eq!(session.current_question_index(), 0); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0))); -} - -#[test] -fn single_select_clicking_selected_option_clears_the_draft() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - false, - false, - &["Rust", "Go"], - )]); - - assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), - AskUserQuestionEffect::RefreshCurrent - ); - assert!(current_draft(&session).is_none()); - assert_eq!(session.current_question_index(), 0); -} - -#[test] -fn drafts_survive_navigation_and_submit_skips_only_unanswered_questions() { - let mut session = build_session(vec![ - build_question("q1", "First", true, false, &["Rust", "Go"]), - build_question("q2", "Second", true, false, &["CLI", "GUI"]), - build_question("q3", "Third", false, true, &["Stable"]), - ]); - - assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), - AskUserQuestionEffect::RefreshCurrent - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigateNext), - AskUserQuestionEffect::ShowQuestion - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigatePrev), - AskUserQuestionEffect::ShowQuestion - ); - assert_eq!( - current_draft(&session).map(|draft| draft.selected_option_indices.len()), - Some(1) - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigateNext), - AskUserQuestionEffect::ShowQuestion - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigateNext), - AskUserQuestionEffect::ShowQuestion - ); - assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - assert_eq!( - session.apply(AskUserQuestionAction::SaveOtherText { - text: Some("nightly toolchain".to_string()), - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - - let effect = session.apply(AskUserQuestionAction::Confirm); - - assert_eq!( - effect, - AskUserQuestionEffect::Submit(vec![ - AskUserQuestionAnswerItem::Answered { - question_id: "q1".to_string(), - selected_options: vec!["Rust".to_string()], - other_text: String::new(), - }, - AskUserQuestionAnswerItem::Skipped { - question_id: "q2".to_string(), - }, - AskUserQuestionAnswerItem::Answered { - question_id: "q3".to_string(), - selected_options: vec![], - other_text: "nightly toolchain".to_string(), - }, - ]) - ); - assert!(matches!( - session.phase(), - AskUserQuestionPhase::Completed { .. } - )); -} - -#[test] -fn multi_select_other_text_does_not_auto_advance_before_last_question() { - let mut session = build_session(vec![ - build_question("q1", "First", true, true, &["Rust"]), - build_question("q2", "Second", false, false, &["CLI"]), - ]); - - assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - assert_eq!( - session.apply(AskUserQuestionAction::SaveOtherText { - text: Some("nightly".to_string()), - }), - AskUserQuestionEffect::RefreshCurrent - ); - assert_eq!(session.current_question_index(), 0); - assert_eq!( - current_draft(&session).and_then(|draft| draft.other_text.as_deref()), - Some("nightly") - ); -} - -#[test] -fn skip_all_moves_session_to_completed_with_skipped_answers() { - let mut session = build_session(vec![ - build_question("q1", "First", true, false, &["Rust"]), - build_question("q2", "Second", false, true, &["Stable"]), - ]); - - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }); - session.apply(AskUserQuestionAction::NavigateNext); - session.apply(AskUserQuestionAction::OpenOtherInput); - session.apply(AskUserQuestionAction::SaveOtherText { - text: Some("nightly".to_string()), - }); - - let effect = session.apply(AskUserQuestionAction::SkipAll); - - assert_eq!( - effect, - AskUserQuestionEffect::Submit(vec![ - AskUserQuestionAnswerItem::Skipped { - question_id: "q1".to_string(), - }, - AskUserQuestionAnswerItem::Skipped { - question_id: "q2".to_string(), - }, - ]) - ); - assert!(matches!( - session.phase(), - AskUserQuestionPhase::Completed { .. } - )); -} - -#[test] -fn other_text_submission_exits_input_and_submits_last_question() { - let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); - - assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - assert!(view_state_for(&session).show_other_input); - - assert_eq!( - session.apply(AskUserQuestionAction::SaveOtherText { - text: Some("nightly".to_string()), - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - - let draft = current_draft(&session).expect("draft should exist"); - assert_eq!(draft.other_text.as_deref(), Some("nightly")); - assert!(!draft.is_other_input_active); - assert!(!view_state_for(&session).show_other_input); - - let effect = session.apply(AskUserQuestionAction::Confirm); - - assert_eq!( - effect, - AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Answered { - question_id: "q1".to_string(), - selected_options: vec![], - other_text: "nightly".to_string(), - }]) - ); -} - -#[test] -fn navigating_next_on_last_question_is_a_noop() { - let mut session = build_session(vec![build_question( - "q1", - "Only", - false, - false, - &["Rust", "Go"], - )]); - - assert_eq!( - session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigateNext), - AskUserQuestionEffect::Noop - ); - assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); - assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0))); -} - -#[test] -fn view_state_shows_other_input() { - let mut session = build_session(vec![ - build_question("q1", "First", false, true, &["Stable"]), - build_question("q2", "Second", false, false, &["CLI"]), - ]); - - assert_eq!( - view_state_for(&session), + ask_user_question_view_state(session.current()), AskUserQuestionViewState { show_other_input: false, } ); + session.apply(AskUserQuestionAction::OpenOtherInput); assert_eq!( - session.apply(AskUserQuestionAction::OpenOtherInput), - AskUserQuestionEffect::FocusOtherInput - ); - assert_eq!( - view_state_for(&session), + ask_user_question_view_state(session.current()), AskUserQuestionViewState { show_other_input: true, } ); + session.apply(AskUserQuestionAction::NavigateNext); assert_eq!( - session.apply(AskUserQuestionAction::SaveOtherText { - text: Some("nightly".to_string()), - }), - AskUserQuestionEffect::ScheduleAutoAdvance - ); - assert_eq!( - session.apply(AskUserQuestionAction::NavigateNext), - AskUserQuestionEffect::ShowQuestion - ); - - assert_eq!( - view_state_for(&session), + ask_user_question_view_state(session.current()), AskUserQuestionViewState { show_other_input: false, } diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index a76b2b5d319..5210adf5e83 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -43,15 +43,15 @@ pub(crate) use action_model::recording_finalize::{ // Consumed by `tui_export` for the `warp_tui` frontend. #[cfg_attr(not(feature = "tui"), allow(unused_imports))] pub use action_model::AIActionStatus; -// Consumed by `tui_export` for the `warp_tui` frontend. -#[cfg(feature = "tui")] -pub use action_model::RequestFileEditsExecutor; #[cfg_attr(target_family = "wasm", allow(unused_imports))] pub(crate) use action_model::{ apply_edits, read_local_file_context, FileReadResult, ReadFileContextResult, RequestFileEditsFormatKind, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId, }; +// Consumed by `tui_export` for the `warp_tui` frontend. +#[cfg(feature = "tui")] +pub use action_model::{AskUserQuestionExecutor, RequestFileEditsExecutor}; pub use action_model::{ BlocklistAIActionEvent, BlocklistAIActionModel, ShellCommandExecutor, ShellCommandExecutorEvent, }; diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index a74ed383360..2706077351d 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -1,5 +1,11 @@ //! Public app APIs used by the `warp_tui` frontend. +pub use ::ai::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType}; +pub use ::ai::agent::action_result::AskUserQuestionAnswerItem; +pub use ::ai::agent::{ + AskUserQuestionAction, AskUserQuestionEffect, AskUserQuestionPhase, AskUserQuestionSession, + QuestionDraft, +}; pub use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; pub use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; pub use repo_metadata::repositories::RepoDetectionSource; @@ -58,11 +64,12 @@ pub use crate::ai::blocklist::history_model::{ }; pub use crate::ai::blocklist::view_util::format_credits; pub use crate::ai::blocklist::{ - block_context_from_terminal_model, AIActionStatus, BlocklistAIActionEvent, - BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, BlocklistAIInputModel, - InputConfig, InputModePolicy, InputModePolicyHandle, InputType, InputTypeAutoDetectionSource, - PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, RunAgentsExecutorEvent, - RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, + block_context_from_terminal_model, AIActionStatus, AskUserQuestionExecutor, + BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIInputModel, InputConfig, InputModePolicy, InputModePolicyHandle, InputType, + InputTypeAutoDetectionSource, PolicyConfigUpdate, RequestFileEditsExecutor, RunAgentsExecutor, + RunAgentsExecutorEvent, RunAgentsSpawningSnapshot, ShellCommandExecutor, + ShellCommandExecutorEvent, }; pub use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, diff --git a/crates/ai/src/agent/ask_user_question_session.rs b/crates/ai/src/agent/ask_user_question_session.rs new file mode 100644 index 00000000000..b794c84cfc2 --- /dev/null +++ b/crates/ai/src/agent/ask_user_question_session.rs @@ -0,0 +1,517 @@ +use std::collections::HashSet; + +use itertools::Itertools; + +use super::action::{AskUserQuestionItem, AskUserQuestionType}; +use super::action_result::AskUserQuestionAnswerItem; + +/// In-progress answer data for a single question while the user is editing. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct QuestionDraft { + pub selected_option_indices: HashSet, + pub other_text: Option, + pub is_other_input_active: bool, +} + +impl QuestionDraft { + pub fn has_answer(&self) -> bool { + !self.selected_option_indices.is_empty() + || self + .other_text + .as_deref() + .is_some_and(|text| !text.is_empty()) + } + + fn is_empty(&self) -> bool { + !self.has_answer() && !self.is_other_input_active + } +} + +/// One slot in the editing state's question-aligned draft list. +/// +/// Keeping untouched questions distinct from drafts with active UI state lets +/// frontends restore navigation without manufacturing empty answers. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +enum QuestionDraftState { + #[default] + Unanswered, + Answered(QuestionDraft), +} + +#[derive(Clone, Copy)] +pub struct AskUserQuestionCurrent<'a> { + pub question: &'a AskUserQuestionItem, + pub draft: Option<&'a QuestionDraft>, +} + +#[derive(Clone, Copy)] +pub enum AskUserQuestionPhase<'a> { + Editing, + Completed { + answers: &'a [AskUserQuestionAnswerItem], + }, +} + +/// Mutable state used only while the questionnaire is accepting answers. +/// +/// Each draft index corresponds to the same index in +/// [`AskUserQuestionSession::questions`]. +#[derive(Clone, Debug, Eq, PartialEq)] +struct AskUserQuestionEditingState { + current_question_index: usize, + drafts: Vec, +} + +impl AskUserQuestionEditingState { + fn new(draft_count: usize) -> Self { + Self { + current_question_index: 0, + drafts: vec![QuestionDraftState::Unanswered; draft_count], + } + } + + fn current_question_index(&self) -> usize { + self.current_question_index + } + + fn current_draft(&self) -> Option<&QuestionDraft> { + self.draft_for_question(self.current_question_index) + } + + fn draft_for_question(&self, index: usize) -> Option<&QuestionDraft> { + let QuestionDraftState::Answered(draft) = self.drafts.get(index)? else { + return None; + }; + Some(draft) + } + + fn is_last_question(&self, question_count: usize) -> bool { + self.current_question_index + 1 >= question_count + } + + fn update_current_draft(&mut self, update: impl FnOnce(&mut QuestionDraft)) { + let Some(slot) = self.drafts.get_mut(self.current_question_index) else { + return; + }; + + let mut draft = match std::mem::take(slot) { + QuestionDraftState::Unanswered => QuestionDraft::default(), + QuestionDraftState::Answered(draft) => draft, + }; + update(&mut draft); + *slot = if draft.is_empty() { + QuestionDraftState::Unanswered + } else { + QuestionDraftState::Answered(draft) + }; + } +} + +/// Top-level questionnaire lifecycle state. +/// +/// Editing owns the current position and drafts; completion replaces that +/// transient state with the final answer payload rendered by both frontends. +#[derive(Clone, Debug, Eq, PartialEq)] +enum AskUserQuestionState { + Editing(AskUserQuestionEditingState), + Completed { + answers: Vec, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AskUserQuestionAction { + ToggleOption { + option_index: usize, + }, + OpenOtherInput, + SaveOtherText { + text: Option, + }, + NavigatePrev, + NavigateNext, + SubmitAnswer { + highlighted_index: Option, + active_other_text: Option, + }, + Confirm, + SkipAll, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AskUserQuestionEffect { + Noop, + RefreshCurrent, + FocusOtherInput, + ShowQuestion, + ScheduleAutoAdvance, + Submit(Vec), +} + +/// Frontend-neutral questionnaire state shared by the GUI and TUI. +pub struct AskUserQuestionSession { + questions: Vec, + state: AskUserQuestionState, +} + +impl AskUserQuestionSession { + pub fn new(mut questions: Vec) -> Self { + // Put multi-select questions before single-select so the last question + // can auto-submit after a single option toggle. + questions.sort_by_key(|question| !question.is_multiselect()); + Self { + state: AskUserQuestionState::Editing(AskUserQuestionEditingState::new(questions.len())), + questions, + } + } + + pub fn phase(&self) -> AskUserQuestionPhase<'_> { + match &self.state { + AskUserQuestionState::Editing(_) => AskUserQuestionPhase::Editing, + AskUserQuestionState::Completed { answers } => { + AskUserQuestionPhase::Completed { answers } + } + } + } + + pub fn is_editing(&self) -> bool { + matches!(self.state, AskUserQuestionState::Editing(_)) + } + + pub fn questions(&self) -> &[AskUserQuestionItem] { + &self.questions + } + + pub fn question_count(&self) -> usize { + self.questions.len() + } + + pub fn has_multiple_questions(&self) -> bool { + self.question_count() > 1 + } + + pub fn current(&self) -> Option> { + let AskUserQuestionState::Editing(editing) = &self.state else { + return None; + }; + + Some(AskUserQuestionCurrent { + question: self.questions.get(editing.current_question_index())?, + draft: editing.current_draft(), + }) + } + + pub fn draft_for_question(&self, index: usize) -> Option<&QuestionDraft> { + let AskUserQuestionState::Editing(editing) = &self.state else { + return None; + }; + editing.draft_for_question(index) + } + + pub fn current_question_index(&self) -> usize { + match &self.state { + AskUserQuestionState::Editing(editing) => editing.current_question_index(), + AskUserQuestionState::Completed { .. } => 0, + } + } + + pub fn is_last_question(&self) -> bool { + match &self.state { + AskUserQuestionState::Editing(editing) => { + editing.is_last_question(self.questions.len()) + } + AskUserQuestionState::Completed { .. } => false, + } + } + + pub fn apply(&mut self, action: AskUserQuestionAction) -> AskUserQuestionEffect { + match action { + AskUserQuestionAction::ToggleOption { option_index } => { + self.toggle_option(option_index) + } + AskUserQuestionAction::OpenOtherInput => self.open_other_input(), + AskUserQuestionAction::SaveOtherText { text } => self.save_other_text(text), + AskUserQuestionAction::NavigatePrev => self.navigate_prev(), + AskUserQuestionAction::NavigateNext => self.navigate_next(), + AskUserQuestionAction::SubmitAnswer { + highlighted_index, + active_other_text, + } => self.submit_answer(highlighted_index, active_other_text), + AskUserQuestionAction::Confirm => self.confirm(), + AskUserQuestionAction::SkipAll => self.skip_all(), + } + } + + fn editing_state_mut(&mut self) -> Option<&mut AskUserQuestionEditingState> { + let AskUserQuestionState::Editing(editing) = &mut self.state else { + return None; + }; + Some(editing) + } + + fn toggle_option(&mut self, option_index: usize) -> AskUserQuestionEffect { + let Some((is_multiselect, auto_advance_enabled)) = self.current().map(|current| { + let is_multiselect = current.question.is_multiselect(); + ( + is_multiselect, + ask_user_question_auto_advance_enabled(is_multiselect, self.is_last_question()), + ) + }) else { + return AskUserQuestionEffect::Noop; + }; + + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + + let mut should_auto_advance_after_toggle = false; + editing.update_current_draft(|draft| { + if is_multiselect { + if !draft.selected_option_indices.insert(option_index) { + draft.selected_option_indices.remove(&option_index); + } + should_auto_advance_after_toggle = + auto_advance_enabled && !draft.selected_option_indices.is_empty(); + return; + } + + if draft.selected_option_indices.contains(&option_index) { + draft.selected_option_indices.clear(); + draft.other_text = None; + draft.is_other_input_active = false; + return; + } + + draft.selected_option_indices.clear(); + draft.selected_option_indices.insert(option_index); + draft.other_text = None; + draft.is_other_input_active = false; + should_auto_advance_after_toggle = auto_advance_enabled; + }); + + if should_auto_advance_after_toggle { + AskUserQuestionEffect::ScheduleAutoAdvance + } else { + AskUserQuestionEffect::RefreshCurrent + } + } + + fn open_other_input(&mut self) -> AskUserQuestionEffect { + let Some(is_multiselect) = self + .current() + .map(|current| current.question.is_multiselect()) + else { + return AskUserQuestionEffect::Noop; + }; + + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + + editing.update_current_draft(|draft| { + if !is_multiselect { + draft.selected_option_indices.clear(); + } + draft.is_other_input_active = true; + }); + AskUserQuestionEffect::FocusOtherInput + } + + fn save_other_text(&mut self, text: Option) -> AskUserQuestionEffect { + let Some(auto_advance_enabled) = self.current().map(|current| { + ask_user_question_auto_advance_enabled( + current.question.is_multiselect(), + self.is_last_question(), + ) + }) else { + return AskUserQuestionEffect::Noop; + }; + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + + editing.update_current_draft(|draft| { + draft.other_text = text; + draft.is_other_input_active = false; + }); + if editing + .current_draft() + .is_some_and(|draft| draft.other_text.is_some()) + { + if auto_advance_enabled { + AskUserQuestionEffect::ScheduleAutoAdvance + } else { + AskUserQuestionEffect::RefreshCurrent + } + } else { + AskUserQuestionEffect::RefreshCurrent + } + } + + fn navigate_prev(&mut self) -> AskUserQuestionEffect { + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + if editing.current_question_index == 0 { + return AskUserQuestionEffect::Noop; + } + + editing.current_question_index -= 1; + AskUserQuestionEffect::ShowQuestion + } + + fn navigate_next(&mut self) -> AskUserQuestionEffect { + let question_count = self.questions.len(); + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + if editing.is_last_question(question_count) { + return AskUserQuestionEffect::Noop; + } + + editing.current_question_index += 1; + AskUserQuestionEffect::ShowQuestion + } + + fn submit_answer( + &mut self, + highlighted_index: Option, + active_other_text: Option, + ) -> AskUserQuestionEffect { + let Some((supports_other, option_count)) = self.current().map(|current| { + ( + current.question.supports_other(), + current + .question + .multiple_choice_options() + .map_or(0, |options| options.len()), + ) + }) else { + return AskUserQuestionEffect::Noop; + }; + + if supports_other && highlighted_index == Some(option_count) { + return self.open_other_input(); + } + + if let Some(option_index) = highlighted_index.filter(|index| *index < option_count) { + let _ = self.toggle_option(option_index); + return self.advance_after_answer(); + } + + if self + .current() + .and_then(|current| current.draft) + .is_some_and(|draft| draft.is_other_input_active) + { + let _ = self.save_other_text(active_other_text); + } + + self.advance_after_answer() + } + + fn advance_after_answer(&mut self) -> AskUserQuestionEffect { + if self + .current() + .and_then(|current| current.draft) + .is_some_and(QuestionDraft::has_answer) + { + AskUserQuestionEffect::ScheduleAutoAdvance + } else { + self.confirm() + } + } + + fn confirm(&mut self) -> AskUserQuestionEffect { + let question_count = self.questions.len(); + let drafts = { + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + if !editing.is_last_question(question_count) { + editing.current_question_index += 1; + return AskUserQuestionEffect::ShowQuestion; + } + + editing.drafts.clone() + }; + let answers = Self::build_answers(&self.questions, &drafts); + + self.state = AskUserQuestionState::Completed { + answers: answers.clone(), + }; + AskUserQuestionEffect::Submit(answers) + } + + fn skip_all(&mut self) -> AskUserQuestionEffect { + let drafts = { + let Some(editing) = self.editing_state_mut() else { + return AskUserQuestionEffect::Noop; + }; + for draft in &mut editing.drafts { + *draft = QuestionDraftState::Unanswered; + } + + editing.drafts.clone() + }; + let answers = Self::build_answers(&self.questions, &drafts); + + self.state = AskUserQuestionState::Completed { + answers: answers.clone(), + }; + AskUserQuestionEffect::Submit(answers) + } + + fn build_answers( + questions: &[AskUserQuestionItem], + drafts: &[QuestionDraftState], + ) -> Vec { + questions + .iter() + .enumerate() + .map(|(index, question)| Self::build_answer(question, drafts.get(index))) + .collect_vec() + } + + fn build_answer( + question: &AskUserQuestionItem, + draft: Option<&QuestionDraftState>, + ) -> AskUserQuestionAnswerItem { + let Some(QuestionDraftState::Answered(draft)) = draft else { + return AskUserQuestionAnswerItem::Skipped { + question_id: question.question_id.clone(), + }; + }; + + let selected_options = match &question.question_type { + AskUserQuestionType::MultipleChoice { options, .. } => draft + .selected_option_indices + .iter() + .copied() + .sorted_unstable() + .filter_map(|index| options.get(index).map(|option| option.label.clone())) + .collect_vec(), + }; + let other_text = draft.other_text.clone().unwrap_or_default(); + + if selected_options.is_empty() && other_text.is_empty() { + AskUserQuestionAnswerItem::Skipped { + question_id: question.question_id.clone(), + } + } else { + AskUserQuestionAnswerItem::Answered { + question_id: question.question_id.clone(), + selected_options, + other_text, + } + } + } +} + +fn ask_user_question_auto_advance_enabled(is_multiselect: bool, is_last_question: bool) -> bool { + is_last_question || !is_multiselect +} + +#[cfg(test)] +#[path = "ask_user_question_session_tests.rs"] +mod tests; diff --git a/crates/ai/src/agent/ask_user_question_session_tests.rs b/crates/ai/src/agent/ask_user_question_session_tests.rs new file mode 100644 index 00000000000..3028d1e90b0 --- /dev/null +++ b/crates/ai/src/agent/ask_user_question_session_tests.rs @@ -0,0 +1,447 @@ +use super::{ + AskUserQuestionAction, AskUserQuestionEffect, AskUserQuestionPhase, AskUserQuestionSession, + QuestionDraft, +}; +use crate::agent::action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType}; +use crate::agent::action_result::AskUserQuestionAnswerItem; + +fn build_question( + question_id: &str, + question: &str, + is_multiselect: bool, + supports_other: bool, + options: &[&str], +) -> AskUserQuestionItem { + AskUserQuestionItem { + question_id: question_id.to_string(), + question: question.to_string(), + question_type: AskUserQuestionType::MultipleChoice { + is_multiselect, + options: options + .iter() + .map(|label| AskUserQuestionOption { + label: (*label).to_string(), + recommended: false, + }) + .collect(), + supports_other, + }, + } +} + +fn build_session(questions: Vec) -> AskUserQuestionSession { + AskUserQuestionSession::new(questions) +} + +fn current_draft(session: &AskUserQuestionSession) -> Option<&QuestionDraft> { + session.current().and_then(|current| current.draft) +} + +#[test] +fn submitting_the_other_row_focuses_the_other_input() { + let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: Some(1), + active_other_text: None, + }), + AskUserQuestionEffect::FocusOtherInput + ); +} + +#[test] +fn submitting_without_an_answer_submits_the_last_question_immediately() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + false, + true, + &["Stable", "Nightly"], + )]); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: None, + active_other_text: None, + }), + AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped { + question_id: "q1".to_string(), + }]) + ); +} + +#[test] +fn submitting_a_selected_answer_schedules_auto_advance() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + false, + false, + &["Stable", "Nightly"], + )]); + + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: None, + active_other_text: None, + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); +} + +#[test] +fn submitting_blank_other_input_submits_the_last_question_immediately() { + let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); + + assert_eq!( + session.apply(AskUserQuestionAction::OpenOtherInput), + AskUserQuestionEffect::FocusOtherInput + ); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: None, + active_other_text: None, + }), + AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Skipped { + question_id: "q1".to_string(), + }]) + ); +} + +#[test] +fn submitting_active_other_text_schedules_auto_advance() { + let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); + + assert_eq!( + session.apply(AskUserQuestionAction::OpenOtherInput), + AskUserQuestionEffect::FocusOtherInput + ); + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: None, + active_other_text: Some("nightly".to_string()), + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert_eq!( + current_draft(&session).and_then(|draft| draft.other_text.as_deref()), + Some("nightly") + ); +} + +#[test] +fn submitting_single_select_option_toggles_it_and_schedules_auto_advance() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + false, + true, + &["Stable", "Nightly"], + )]); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: Some(1), + active_other_text: None, + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); +} + +#[test] +fn submitting_non_last_multi_select_option_toggles_it_and_schedules_auto_advance() { + let mut session = build_session(vec![ + build_question("q1", "First", true, true, &["Stable", "Nightly"]), + build_question("q2", "Second", false, false, &["CLI"]), + ]); + + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: Some(1), + active_other_text: None, + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); +} + +#[test] +fn submitting_answered_non_last_multi_select_option_keeps_selection_and_advances() { + let mut session = build_session(vec![ + build_question("q1", "First", true, true, &["Stable", "Nightly"]), + build_question("q2", "Second", false, false, &["CLI"]), + ]); + + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), + AskUserQuestionEffect::RefreshCurrent + ); + assert_eq!( + session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index: Some(1), + active_other_text: None, + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert!(current_draft(&session).is_some_and(|draft| { + draft.selected_option_indices.contains(&0) && draft.selected_option_indices.contains(&1) + })); +} + +#[test] +fn single_select_non_last_toggle_schedules_auto_advance() { + let mut session = build_session(vec![ + build_question("q1", "First", false, false, &["Rust", "Go"]), + build_question("q2", "Second", false, false, &["CLI", "GUI"]), + ]); + + let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }); + + assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance); + assert_eq!(session.current_question_index(), 0); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); + assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); +} + +#[test] +fn multi_select_non_last_toggle_does_not_auto_advance() { + let mut session = build_session(vec![ + build_question("q1", "First", true, false, &["Rust", "Go"]), + build_question("q2", "Second", false, false, &["CLI", "GUI"]), + ]); + + let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 1 }); + + assert_eq!(effect, AskUserQuestionEffect::RefreshCurrent); + assert_eq!(session.current_question_index(), 0); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&1))); + assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); +} + +#[test] +fn last_multi_select_toggle_schedules_auto_advance() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + true, + false, + &["Rust", "Go"], + )]); + + let effect = session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }); + + assert_eq!(effect, AskUserQuestionEffect::ScheduleAutoAdvance); + assert_eq!(session.current_question_index(), 0); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0))); +} + +#[test] +fn single_select_clicking_selected_option_clears_the_draft() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + false, + false, + &["Rust", "Go"], + )]); + + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), + AskUserQuestionEffect::RefreshCurrent + ); + assert!(current_draft(&session).is_none()); + assert_eq!(session.current_question_index(), 0); +} + +#[test] +fn drafts_survive_navigation_and_submit_skips_only_unanswered_questions() { + let mut session = build_session(vec![ + build_question("q1", "First", true, false, &["Rust", "Go"]), + build_question("q2", "Second", true, false, &["CLI", "GUI"]), + build_question("q3", "Third", false, true, &["Stable"]), + ]); + + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), + AskUserQuestionEffect::RefreshCurrent + ); + assert_eq!( + session.apply(AskUserQuestionAction::NavigateNext), + AskUserQuestionEffect::ShowQuestion + ); + assert_eq!( + session.apply(AskUserQuestionAction::NavigatePrev), + AskUserQuestionEffect::ShowQuestion + ); + assert_eq!( + current_draft(&session).map(|draft| draft.selected_option_indices.len()), + Some(1) + ); + assert_eq!( + session.apply(AskUserQuestionAction::NavigateNext), + AskUserQuestionEffect::ShowQuestion + ); + assert_eq!( + session.apply(AskUserQuestionAction::NavigateNext), + AskUserQuestionEffect::ShowQuestion + ); + assert_eq!( + session.apply(AskUserQuestionAction::OpenOtherInput), + AskUserQuestionEffect::FocusOtherInput + ); + assert_eq!( + session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("nightly toolchain".to_string()), + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + + let effect = session.apply(AskUserQuestionAction::Confirm); + + assert_eq!( + effect, + AskUserQuestionEffect::Submit(vec![ + AskUserQuestionAnswerItem::Answered { + question_id: "q1".to_string(), + selected_options: vec!["Rust".to_string()], + other_text: String::new(), + }, + AskUserQuestionAnswerItem::Skipped { + question_id: "q2".to_string(), + }, + AskUserQuestionAnswerItem::Answered { + question_id: "q3".to_string(), + selected_options: vec![], + other_text: "nightly toolchain".to_string(), + }, + ]) + ); + assert!(matches!( + session.phase(), + AskUserQuestionPhase::Completed { .. } + )); +} + +#[test] +fn multi_select_other_text_does_not_auto_advance_before_last_question() { + let mut session = build_session(vec![ + build_question("q1", "First", true, true, &["Rust"]), + build_question("q2", "Second", false, false, &["CLI"]), + ]); + + assert_eq!( + session.apply(AskUserQuestionAction::OpenOtherInput), + AskUserQuestionEffect::FocusOtherInput + ); + assert_eq!( + session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("nightly".to_string()), + }), + AskUserQuestionEffect::RefreshCurrent + ); + assert_eq!(session.current_question_index(), 0); + assert_eq!( + current_draft(&session).and_then(|draft| draft.other_text.as_deref()), + Some("nightly") + ); +} + +#[test] +fn skip_all_moves_session_to_completed_with_skipped_answers() { + let mut session = build_session(vec![ + build_question("q1", "First", true, false, &["Rust"]), + build_question("q2", "Second", false, true, &["Stable"]), + ]); + + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }); + session.apply(AskUserQuestionAction::NavigateNext); + session.apply(AskUserQuestionAction::OpenOtherInput); + session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("nightly".to_string()), + }); + + let effect = session.apply(AskUserQuestionAction::SkipAll); + + assert_eq!( + effect, + AskUserQuestionEffect::Submit(vec![ + AskUserQuestionAnswerItem::Skipped { + question_id: "q1".to_string(), + }, + AskUserQuestionAnswerItem::Skipped { + question_id: "q2".to_string(), + }, + ]) + ); + assert!(matches!( + session.phase(), + AskUserQuestionPhase::Completed { .. } + )); +} + +#[test] +fn other_text_submission_exits_input_and_submits_last_question() { + let mut session = build_session(vec![build_question("q1", "Only", false, true, &["Stable"])]); + + assert_eq!( + session.apply(AskUserQuestionAction::OpenOtherInput), + AskUserQuestionEffect::FocusOtherInput + ); + assert!(current_draft(&session).is_some_and(|draft| draft.is_other_input_active)); + + assert_eq!( + session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("nightly".to_string()), + }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + + let draft = current_draft(&session).expect("draft should exist"); + assert_eq!(draft.other_text.as_deref(), Some("nightly")); + assert!(!draft.is_other_input_active); + + let effect = session.apply(AskUserQuestionAction::Confirm); + + assert_eq!( + effect, + AskUserQuestionEffect::Submit(vec![AskUserQuestionAnswerItem::Answered { + question_id: "q1".to_string(), + selected_options: vec![], + other_text: "nightly".to_string(), + }]) + ); +} + +#[test] +fn navigating_next_on_last_question_is_a_noop() { + let mut session = build_session(vec![build_question( + "q1", + "Only", + false, + false, + &["Rust", "Go"], + )]); + + assert_eq!( + session.apply(AskUserQuestionAction::ToggleOption { option_index: 0 }), + AskUserQuestionEffect::ScheduleAutoAdvance + ); + assert_eq!( + session.apply(AskUserQuestionAction::NavigateNext), + AskUserQuestionEffect::Noop + ); + assert!(matches!(session.phase(), AskUserQuestionPhase::Editing)); + assert!(current_draft(&session).is_some_and(|draft| draft.selected_option_indices.contains(&0))); +} diff --git a/crates/ai/src/agent/mod.rs b/crates/ai/src/agent/mod.rs index d4f68efa997..0875591e258 100644 --- a/crates/ai/src/agent/mod.rs +++ b/crates/ai/src/agent/mod.rs @@ -1,10 +1,14 @@ pub mod action; pub mod action_result; +mod ask_user_question_session; mod citation; pub mod convert; pub mod document_action_presentation; pub mod file_locations; pub mod orchestration_config; - +pub use ask_user_question_session::{ + AskUserQuestionAction, AskUserQuestionCurrent, AskUserQuestionEffect, AskUserQuestionPhase, + AskUserQuestionSession, QuestionDraft, +}; pub use citation::{AIAgentCitation, UnknownCitationTypeError}; pub use file_locations::{group_file_contexts_for_display, FileLocations}; diff --git a/crates/warp_tui/src/agent_block.rs b/crates/warp_tui/src/agent_block.rs index e8e59e0488e..f2c4c1540f2 100644 --- a/crates/warp_tui/src/agent_block.rs +++ b/crates/warp_tui/src/agent_block.rs @@ -33,6 +33,7 @@ use warpui_core::{ ViewHandle, }; +use super::tui_ask_question_view::{TuiAskQuestionView, TuiAskQuestionViewEvent}; use super::tui_file_edits_view::{TuiFileEditsView, TuiFileEditsViewEvent}; use super::tui_shell_command_view::{TuiShellCommandView, TuiShellCommandViewEvent}; use crate::agent_block_sections::{ @@ -159,6 +160,7 @@ impl CollapsibleSectionStates { /// [`TuiAIBlockSection::render_element`]; a tool type gets a variant here only /// when it needs owned state or interactivity. enum TuiToolCallView { + AskQuestion(ViewHandle), FileEdits(ViewHandle), Plan(ViewHandle), ShellCommand(ViewHandle), @@ -169,6 +171,7 @@ impl TuiToolCallView { /// The registered view's entity id, for [`TuiView::child_view_ids`]. fn view_id(&self) -> EntityId { match self { + Self::AskQuestion(view) => view.id(), Self::FileEdits(view) => view.id(), Self::Plan(view) => view.id(), Self::ShellCommand(view) => view.id(), @@ -179,6 +182,7 @@ impl TuiToolCallView { /// Renders the registered child view into the block's element tree. fn render_child(&self) -> TuiChildView { match self { + Self::AskQuestion(view) => TuiChildView::new(view), Self::FileEdits(view) => TuiChildView::new(view), Self::Plan(view) => TuiChildView::new(view), Self::ShellCommand(view) => TuiChildView::new(view), @@ -327,6 +331,7 @@ impl TuiAIBlock { ) { let status = self.block_model.status(ctx); let output_streaming = status.is_streaming(); + let mut ask_question_actions = Vec::new(); let mut file_edit_action_ids = Vec::new(); let mut plan_actions = Vec::new(); let mut shell_command_actions = Vec::new(); @@ -341,7 +346,9 @@ impl TuiAIBlock { continue; }; self.action_ids.insert(action.id.clone()); - if matches!(&action.action, AIAgentActionType::RequestFileEdits { .. }) { + if let AIAgentActionType::AskUserQuestion { questions } = &action.action { + ask_question_actions.push((action.id.clone(), questions.clone())); + } else if matches!(&action.action, AIAgentActionType::RequestFileEdits { .. }) { file_edit_action_ids.push(action.id.clone()); } else if matches!( &action.action, @@ -359,6 +366,41 @@ impl TuiAIBlock { } } + for (action_id, questions) in ask_question_actions { + let needs_init = match self.action_views.get(&action_id) { + Some(TuiToolCallView::AskQuestion(view)) => { + !view.as_ref(ctx).matches_action(&action_id, &questions) + } + Some( + TuiToolCallView::FileEdits(_) + | TuiToolCallView::Plan(_) + | TuiToolCallView::ShellCommand(_) + | TuiToolCallView::OrchestrationBlock(_), + ) + | None => true, + }; + if !needs_init { + continue; + } + let view_action_id = action_id.clone(); + let action_model = action_model.clone(); + let conversation_id = self.conversation_id; + let view = ctx.add_typed_action_tui_view(move |ctx| { + TuiAskQuestionView::new( + action_model, + conversation_id, + view_action_id, + questions, + ctx, + ) + }); + ctx.subscribe_to_view(&view, |me, _, event, ctx| match event { + TuiAskQuestionViewEvent::LayoutChanged => me.invalidate_layout(ctx), + }); + self.action_views + .insert(action_id, TuiToolCallView::AskQuestion(view)); + ctx.notify(); + } for action_id in file_edit_action_ids { if self.action_views.contains_key(&action_id) { continue; @@ -524,7 +566,8 @@ impl TuiAIBlock { .is_awaiting_confirmation(ctx) .then(|| view.clone()), // These tool views render inline and never replace the input. - TuiToolCallView::FileEdits(_) + TuiToolCallView::AskQuestion(_) + | TuiToolCallView::FileEdits(_) | TuiToolCallView::Plan(_) | TuiToolCallView::ShellCommand(_) => None, } @@ -724,7 +767,8 @@ impl TuiAIBlock { self.last_measured_width.get() != Some(width) || self.block_model.status(app).is_streaming() || self.action_views.values().any(|view| match view { - TuiToolCallView::FileEdits(_) + TuiToolCallView::AskQuestion(_) + | TuiToolCallView::FileEdits(_) | TuiToolCallView::Plan(_) | TuiToolCallView::OrchestrationBlock(_) => false, TuiToolCallView::ShellCommand(view) => { diff --git a/crates/warp_tui/src/agent_block_tests.rs b/crates/warp_tui/src/agent_block_tests.rs index ef90a088808..9d70175d679 100644 --- a/crates/warp_tui/src/agent_block_tests.rs +++ b/crates/warp_tui/src/agent_block_tests.rs @@ -609,6 +609,86 @@ fn keyboard_toggle_targets_latest_exposed_plan_in_message_order() { }); } #[test] +fn ask_user_question_action_registers_a_stateful_child_view() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let action = ask_user_question_action("ask-1", "Which one?"); + let action_id = action.id.clone(); + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![action_message("message-1", action)]), + }, + ); + app.read(|ctx| { + assert!(matches!( + block.as_ref(ctx).action_views.get(&action_id), + Some(TuiToolCallView::AskQuestion(_)) + )); + }); + }); +} + +#[test] +fn streamed_ask_user_question_payload_replaces_the_initial_empty_child_view() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + let action_id = AIAgentActionId::from("ask-1".to_owned()); + let initial_action = AIAgentAction { + id: action_id.clone(), + task_id: TaskId::new("task-1".to_owned()), + action: AIAgentActionType::AskUserQuestion { + questions: Vec::new(), + }, + requires_result: true, + }; + let block = test_agent_block( + &mut app, + FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![action_message("message-1", initial_action)]), + }, + ); + let initial_view_id = app.read(|ctx| { + let Some(TuiToolCallView::AskQuestion(view)) = + block.as_ref(ctx).action_views.get(&action_id) + else { + panic!("initial ask-question child view"); + }; + assert!(view.as_ref(ctx).matches_action(&action_id, &[])); + view.id() + }); + + block.update(&mut app, |block, ctx| { + block.replace_model( + block.conversation_id, + Rc::new(FakeAgentBlockModel { + inputs: Vec::new(), + status: complete_output_messages(vec![action_message( + "message-1", + ask_user_question_action("ask-1", "Which one?"), + )]), + }), + ); + let action_model = block.action_model.clone(); + block.sync_action_views(&action_model, ctx); + }); + + app.read(|ctx| { + let Some(TuiToolCallView::AskQuestion(view)) = + block.as_ref(ctx).action_views.get(&action_id) + else { + panic!("updated ask-question child view"); + }; + assert_ne!(view.id(), initial_view_id); + assert!(view + .as_ref(ctx) + .matches_action(&action_id, &ask_user_question_items("Which one?"))); + }); + }); +} +#[test] fn agent_block_ignores_unsupported_message_variants() { App::test((), |mut app| async move { let block = test_agent_block( @@ -1494,6 +1574,31 @@ fn test_agent_block(app: &mut App, model: FakeAgentBlockModel) -> ViewHandle AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(id.to_owned()), + task_id: TaskId::new("task-1".to_owned()), + action: AIAgentActionType::AskUserQuestion { + questions: ask_user_question_items(question), + }, + requires_result: true, + } +} + +fn ask_user_question_items(question: &str) -> Vec { + vec![ai::agent::action::AskUserQuestionItem { + question_id: "q1".to_owned(), + question: question.to_owned(), + question_type: ai::agent::action::AskUserQuestionType::MultipleChoice { + is_multiselect: false, + options: vec![ai::agent::action::AskUserQuestionOption { + label: "A".to_owned(), + recommended: false, + }], + supports_other: false, + }, + }] +} impl AIBlockModel for FakeAgentBlockModel { type View = TuiAIBlock; diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index 5de3270d800..f66230dda13 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -21,7 +21,7 @@ use warpui::EntityIdMap; use warpui_core::elements::tui::{ TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiPoint, TuiRect, TuiScene, - TuiScreenPosition, TuiSize, + TuiScreenPosition, TuiSize, TuiText, }; use warpui_core::event::{KeyEventDetails, ModifiersState}; use warpui_core::keymap::Keystroke; @@ -1333,6 +1333,33 @@ fn laid_out_element( let size = element.layout(TuiConstraint::loose(TuiSize::new(W, 20)), &mut lctx, ctx); (element, TuiRect::new(0, 0, size.width, size.height)) } +struct FocusTarget; + +impl Entity for FocusTarget { + type Event = (); +} + +impl TuiView for FocusTarget { + fn ui_name() -> &'static str { + "FocusTarget" + } + + fn render(&self, _ctx: &AppContext) -> Box { + TuiText::new("").finish() + } +} + +fn printable_key(character: char) -> TuiEvent { + TuiEvent::KeyDown { + keystroke: Keystroke { + key: character.to_string(), + ..Default::default() + }, + chars: character.to_string(), + details: KeyEventDetails::default(), + is_composing: false, + } +} /// Paints `element` and returns its retained scene. fn paint_event_scene(element: &mut dyn TuiElement, area: TuiRect) -> Rc { @@ -1349,6 +1376,42 @@ fn paint_event_scene(element: &mut dyn TuiElement, area: TuiRect) -> Rc, + ctx: &AppContext, + event: &TuiEvent, +) -> bool { + let (mut element, area) = laid_out_element(view, ctx); + let scene = paint_event_scene(&mut element, area); + let mut rendered_views = EntityIdMap::default(); + let mut event_ctx = TuiEventContext::new(scene, &mut rendered_views); + event_ctx.set_origin_view(Some(view.id())); + element.dispatch_event(event, &mut event_ctx, ctx) +} + +#[test] +fn printable_input_is_accepted_only_while_focused() { + App::test((), |mut app| async move { + let view = app.update(build_view); + + assert!(app.read(|ctx| view.is_focused(ctx))); + assert!(app.read(|ctx| dispatch_element_event(&view, ctx, &printable_key('a')))); + assert_eq!( + app.read(|ctx| cursor_and_height(&view, ctx).0), + Some((0, 0)) + ); + + let focus_target = view.update(&mut app, |_, ctx| ctx.add_tui_view(|_| FocusTarget)); + focus_target.update(&mut app, |_, ctx| ctx.focus_self()); + + assert!(!app.read(|ctx| view.is_focused(ctx))); + assert!(!app.read(|ctx| dispatch_element_event(&view, ctx, &printable_key('b')))); + assert_eq!(app.read(|ctx| cursor_and_height(&view, ctx).0), None); + + view.update(&mut app, |_, ctx| ctx.focus_self()); + assert!(app.read(|ctx| dispatch_element_event(&view, ctx, &printable_key('c')))); + }); +} /// Drives the full mouse path for `event`: lay out the element, map the event to /// its editor action, and apply the corresponding [`TuiInputAction`] to the view. diff --git a/crates/warp_tui/src/keybindings.rs b/crates/warp_tui/src/keybindings.rs index 60e9cecef80..a832e6a379f 100644 --- a/crates/warp_tui/src/keybindings.rs +++ b/crates/warp_tui/src/keybindings.rs @@ -81,6 +81,7 @@ pub(crate) fn init(app: &mut AppContext) { TuiEditorViewAction::Command, ); crate::orchestration_block::init(app); + crate::tui_ask_question_view::init(app); register_binding_validators(app); } @@ -128,7 +129,7 @@ fn register_binding_validators(app: &mut AppContext) { app.register_tui_binding_validator::(is_tui_owned_binding); } -fn is_tui_owned_binding(binding: BindingLens) -> IsBindingValid { +pub(crate) fn is_tui_owned_binding(binding: BindingLens) -> IsBindingValid { // Non-keystroke triggers (palette-only `Empty`, `Standard`, `Custom`) // can never fire from TUI keyboard input, so they are exempt. if !matches!(binding.trigger, Trigger::Keystrokes(_)) { diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 2cd3caec544..c5b45c1f901 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -16,6 +16,7 @@ pub mod input; pub mod root_view; pub mod session; mod telemetry; +mod tui_ask_question_view; mod tui_builder; mod ui; diff --git a/crates/warp_tui/src/option_selector.rs b/crates/warp_tui/src/option_selector.rs index 843d582f516..a8dbdc55d96 100644 --- a/crates/warp_tui/src/option_selector.rs +++ b/crates/warp_tui/src/option_selector.rs @@ -1,7 +1,7 @@ //! [`TuiOptionSelector`]: a reusable single-select option list for TUI //! permission prompts, rendered from a frontend-neutral -//! [`OptionSnapshot`]. One configuration page shows a header (title, -//! "n of m" position, question), a selectable option list with viewport +//! [`OptionSnapshot`]. One configuration page may show a header (title, "n of +//! m" position, question) above a selectable option list with viewport //! scrolling, optional Loading/Failed/Empty status rows, and an optional //! custom-text footer editor. //! @@ -10,6 +10,7 @@ //! since the selector is only rendered while its host is the active blocking //! interaction. Escape remains host policy, with an element-level fallback //! through [`TuiOptionSelector::handle_back`]. +use std::collections::HashSet; use warp::tui_export::{OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus}; use warp_search_core::inline_menu::InlineMenuSelection; @@ -34,15 +35,22 @@ pub(crate) const MAX_VISIBLE_OPTION_ROWS: usize = 6; /// Validation copy shown when the custom-text editor is submitted empty. const CUSTOM_TEXT_EMPTY_ERROR: &str = "Enter a value to continue."; -/// Renderable fields for one selector page. +/// Optional header rendered above a selector page. #[derive(Clone, Debug, PartialEq)] -pub(crate) struct OptionSelectorPage { +pub(crate) struct OptionSelectorHeader { /// Short field label shown in the header row. pub(crate) field_label: String, /// One-based position in the current page sequence: `(current, total)`. pub(crate) position: (usize, usize), /// Full prompt shown above the available options. pub(crate) prompt: String, +} + +/// Renderable fields for one selector page. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct OptionSelectorPage { + /// Header metadata, or `None` when the embedding view provides its own. + pub(crate) header: Option, /// Options and catalog status rendered on this page. pub(crate) snapshot: OptionSnapshot, /// Whether this page offers label filtering. @@ -52,9 +60,7 @@ pub(crate) struct OptionSelectorPage { impl Default for OptionSelectorPage { fn default() -> Self { Self { - field_label: String::new(), - position: (0, 0), - prompt: String::new(), + header: None, snapshot: OptionSnapshot { rows: Vec::new(), selected_id: None, @@ -73,6 +79,8 @@ pub(crate) enum TuiOptionSelectorEvent { Confirmed { id: String }, /// The custom-text footer editor was submitted with a valid value. CustomTextSubmitted { value: String }, + /// The question-card Other editor was opened. + CustomTextOpened, /// The Retry affordance of a `Failed` catalog was activated. RetryRequested, /// The selector asked to be dismissed (element-level Escape fallback for @@ -247,6 +255,12 @@ pub(crate) struct TuiOptionSelector { interaction: SelectorInteractionState, search_field: Option>, custom_text: CustomTextState, + /// Selected question option ids, independent of the keyboard highlight. + selected_ids: HashSet, + /// Whether selected question options render checkmarks. + show_selection_markers: bool, + /// Whether this selector is embedded in an AskUserQuestion card. + question_style: bool, /// Whether the selector itself (the list zone) is focused. focused: bool, /// Per-item mouse state, indexed like [`Self::items`]. Owned here (not @@ -273,6 +287,9 @@ impl TuiOptionSelector { interaction: SelectorInteractionState::default(), search_field: None, custom_text: CustomTextState::new(custom_text_editor), + selected_ids: HashSet::new(), + show_selection_markers: false, + question_style: false, focused: false, item_mouse_states: Vec::new(), } @@ -327,6 +344,53 @@ impl TuiOptionSelector { self.invalidate_layout(ctx); } + /// Adapts this selector for an ask-question card. The questionnaire owns + /// the committed selections; the selector retains its keyboard highlight. + pub(crate) fn set_question_state( + &mut self, + selected_ids: HashSet, + show_selection_markers: bool, + ctx: &mut ViewContext, + ) { + self.question_style = true; + self.selected_ids = selected_ids; + self.show_selection_markers = show_selection_markers; + ctx.notify(); + } + + /// The highlighted question-option index, including the trailing Other + /// entry when present. + pub(crate) fn highlighted_question_index(&self) -> Option { + (!self.custom_text.is_editing()) + .then(|| self.interaction.selection.selected_index()) + .flatten() + } + + /// The current inline Other buffer, trimmed for questionnaire transitions. + pub(crate) fn active_custom_text(&self, ctx: &AppContext) -> Option { + self.custom_text.is_editing().then(|| { + self.custom_text + .editor + .as_ref(ctx) + .text(ctx) + .trim() + .to_owned() + }) + } + + #[cfg(test)] + pub(crate) fn set_active_custom_text_for_test( + &mut self, + text: &str, + ctx: &mut ViewContext, + ) { + if self.custom_text.is_editing() { + self.custom_text + .editor + .update(ctx, |editor, ctx| editor.set_text(text, ctx)); + } + } + /// Refreshes the snapshot in place after a live catalog change, /// preserving the active selection when it still exists and falling back /// to the snapshot's committed selection otherwise. @@ -412,6 +476,9 @@ impl TuiOptionSelector { /// Activates the custom editor with the last committed value. fn begin_custom_text_editing(&mut self, ctx: &mut ViewContext) { self.custom_text.begin_editing(ctx); + if self.question_style { + ctx.emit(TuiOptionSelectorEvent::CustomTextOpened); + } ctx.focus(&self.custom_text.editor); ctx.notify(); } @@ -684,9 +751,9 @@ impl TuiOptionSelector { // ── Rendering ─────────────────────────────────────────────────── /// One header block: field label + position, then the page prompt. - fn render_header(&self, builder: &TuiUiBuilder) -> Box { - let (current, total) = self.page.position; - let title = TuiText::new(self.page.field_label.clone()) + fn render_header(header: &OptionSelectorHeader, builder: &TuiUiBuilder) -> Box { + let (current, total) = header.position; + let title = TuiText::new(header.field_label.clone()) .with_style(builder.primary_text_style()) .truncate() .finish(); @@ -717,7 +784,7 @@ impl TuiOptionSelector { .child(title_row) .child(TuiText::new(" ").finish()) .child( - TuiText::new(self.page.prompt.clone()) + TuiText::new(header.prompt.clone()) .with_style(builder.primary_text_style().add_modifier(Modifier::BOLD)) .finish(), ) @@ -730,19 +797,29 @@ impl TuiOptionSelector { &self, row: &OptionRow, digit: Option, - is_selected: bool, + is_highlighted: bool, builder: &TuiUiBuilder, ) -> Box { let disabled = row.disabled_reason.is_some(); - let label_style = if is_selected { + let is_selected = if self.question_style { + self.selected_ids.contains(&row.id) + } else { + is_highlighted + }; + let selected_style = if self.question_style { + builder.question_option_selected_style() + } else { builder.option_selector_selected_style() + }; + let label_style = if is_highlighted || is_selected { + selected_style } else if disabled { builder.dim_text_style() } else { builder.primary_text_style() }; - let detail_style = if is_selected { - builder.option_selector_selected_style() + let detail_style = if is_highlighted { + selected_style } else if disabled { builder.dim_text_style() } else { @@ -752,10 +829,18 @@ impl TuiOptionSelector { Some(digit) => format!("({digit}) "), None => " ".to_string(), }; - let mut spans = vec![ - (digit_prefix, detail_style), - (row.label.clone(), label_style), - ]; + let mut spans = vec![(digit_prefix, detail_style)]; + if self.show_selection_markers { + spans.push(( + if is_selected { "✓ " } else { " " }.to_string(), + if is_selected { + builder.success_glyph_style() + } else { + builder.muted_text_style() + }, + )); + } + spans.push((row.label.clone(), label_style)); let badge = match row.badge { Some(OptionBadge::Default) => Some("default"), Some(OptionBadge::Recent) => Some("recent"), @@ -776,12 +861,16 @@ impl TuiOptionSelector { &self, text: String, digit: Option, - is_selected: bool, + is_highlighted: bool, style: TuiStyle, builder: &TuiUiBuilder, ) -> Box { - let style = if is_selected { - builder.option_selector_selected_style() + let style = if is_highlighted { + if self.question_style { + builder.question_option_selected_style() + } else { + builder.option_selector_selected_style() + } } else { style }; @@ -965,7 +1054,10 @@ impl TuiView for TuiOptionSelector { fn render(&self, app: &AppContext) -> Box { let builder = TuiUiBuilder::from_app(app); - let mut content = TuiFlex::column().child(self.render_header(&builder)); + let mut content = TuiFlex::column(); + if let Some(header) = &self.page.header { + content.add_child(Self::render_header(header, &builder)); + } if let Some(search_field) = self .page .searchable diff --git a/crates/warp_tui/src/option_selector_tests.rs b/crates/warp_tui/src/option_selector_tests.rs index 56e9f75014d..d99646cfe51 100644 --- a/crates/warp_tui/src/option_selector_tests.rs +++ b/crates/warp_tui/src/option_selector_tests.rs @@ -14,8 +14,8 @@ use warpui_core::keymap::Keystroke; use warpui_core::{App, AppContext, TuiView as _, TypedActionView as _, ViewHandle}; use super::{ - OptionSelectorPage, SelectorItem, TuiOptionSelector, TuiOptionSelectorAction, - TuiOptionSelectorEvent, + OptionSelectorHeader, OptionSelectorPage, SelectorItem, TuiOptionSelector, + TuiOptionSelectorAction, TuiOptionSelectorEvent, }; use crate::editor_element::TuiEditorAction; use crate::editor_interaction::TuiEditorCommand; @@ -60,9 +60,11 @@ fn snapshot_of(rows: Vec, selected: Option<&str>) -> OptionSnapshot { /// Builds one selector page with shared test metadata. fn page(snapshot: OptionSnapshot, searchable: bool) -> OptionSelectorPage { OptionSelectorPage { - field_label: "Host".to_string(), - position: (4, 6), - prompt: "Which host should run the agents?".to_string(), + header: Some(OptionSelectorHeader { + field_label: "Host".to_string(), + position: (4, 6), + prompt: "Which host should run the agents?".to_string(), + }), snapshot, searchable, } @@ -493,6 +495,29 @@ fn renders_field_label_position_prompt_and_initial_selection() { }); } +#[test] +fn normal_selector_selected_row_does_not_depend_on_question_selected_ids() { + App::test((), |mut app| async move { + let (selector, _) = add_selector(&mut app); + set_page(&mut app, &selector, snapshot(&["a", "b"], Some("b"))); + + assert!(selector.read(&app, |selector, _| { + !selector.question_style && selector.selected_ids.is_empty() + })); + + let buffer = render_buffer(&app, &selector, 60); + let selected_fg = app + .read(TuiUiBuilder::from_app) + .option_selector_selected_style() + .fg + .expect("selected option has a foreground"); + for cell in [&buffer[(0, 4)], &buffer[(4, 4)]] { + assert_eq!(cell.fg, selected_fg); + assert!(cell.modifier.contains(Modifier::BOLD)); + } + }); +} + #[test] fn up_and_down_move_the_selection_and_enter_confirms_it() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index efa0ecaa92c..fe08be90046 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -38,7 +38,9 @@ use configuration::{ }; use crate::keybindings::TUI_BINDING_GROUP; -use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::option_selector::{ + OptionSelectorHeader, OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent, +}; use crate::orchestrated_agent_identity_styling::AgentIdentity; use crate::tui_builder::TuiUiBuilder; @@ -469,9 +471,11 @@ impl TuiOrchestrationBlock { Self::page_sequence(&self.orchestration_edit_state.orchestration_config_state); let position = sequence.iter().position(|p| *p == page).unwrap_or(0) + 1; let selector_page = OptionSelectorPage { - field_label: "Edit agent configuration".to_string(), - position: (position, sequence.len()), - prompt: page.question(self.request_fields.agent_run_configs.len()), + header: Some(OptionSelectorHeader { + field_label: "Edit agent configuration".to_string(), + position: (position, sequence.len()), + prompt: page.question(self.request_fields.agent_run_configs.len()), + }), snapshot: self.snapshot_for_page(page, ctx), searchable: page.is_searchable(), }; @@ -604,6 +608,7 @@ impl TuiOrchestrationBlock { self.finish_page_confirmation(ConfigPage::Host, ctx); } } + TuiOptionSelectorEvent::CustomTextOpened => {} TuiOptionSelectorEvent::RetryRequested => { self.pending_page_navigation = None; self.ensure_auth_secrets_fetched(ctx); diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index f74586b115f..4ed26bf5360 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -34,6 +34,7 @@ fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRe name: "researcher".to_string(), prompt: "research".to_string(), title: "Researcher".to_string(), + agent_identity_uid: String::new(), }], plan_id: "plan-1".to_string(), harness_auth_secret_name: None, diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index c1c0a49c823..3d9b45b0895 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -15,13 +15,14 @@ use warp::tui_export::{ block_context_from_terminal_model, build_slash_command_mixer, detect_possible_git_repo, export_conversation_markdown, prepare_conversation_block_restoration, record_saved_prompt_accepted, record_static_slash_command_accepted, saved_prompt_text_for_id, - slash_command_selection_behavior, throttle, AIAgentActionId, AIAgentContext, - AIAgentPtyWriteMode, AIConversation, AIConversationId, AcceptSlashCommandOrSavedPrompt, - ActiveSession, ActiveSessionEvent, AgentConversationEntryId, AgentConversationListEntryState, - AgentConversationsModel, AgentInteractionMetadata, AgentViewEntryOrigin, BlockId, - BlocklistAIActionModel, BlocklistAIContextModel, BlocklistAIController, - BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputModel, CLISubagentController, - CLISubagentEvent, CLISubagentTarget, CancellationReason, ChangelogModel, ChangelogModelEvent, + slash_command_selection_behavior, throttle, AIAgentActionId, AIAgentActionResultType, + AIAgentContext, AIAgentPtyWriteMode, AIConversation, AIConversationId, + AcceptSlashCommandOrSavedPrompt, ActiveSession, ActiveSessionEvent, AgentConversationEntryId, + AgentConversationListEntryState, AgentConversationsModel, AgentInteractionMetadata, + AgentViewEntryOrigin, BlockId, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, + BlocklistAIHistoryModel, BlocklistAIInputModel, CLISubagentController, CLISubagentEvent, + CLISubagentTarget, CancellationReason, ChangelogModel, ChangelogModelEvent, ChangelogRequestType, CloudConversationData, CommandExecutionSource, ConversationFileExport, ConversationSelection, ConversationSelectionHandle, ConversationUsageTotals, ExecuteCommandEvent, GetRelevantFilesController, GitRepoModels, GitRepoStatusModel, @@ -826,6 +827,20 @@ impl TuiTerminalSessionView { view.handle_accepted_mcp_action(*action, ctx); } }); + ctx.subscribe_to_model(&action_model, |view, action_model, event, ctx| { + let BlocklistAIActionEvent::FinishedAction { action_id, .. } = event else { + return; + }; + let finished_asking_question = action_model + .as_ref(ctx) + .get_action_result(action_id) + .is_some_and(|result| { + matches!(&result.result, AIAgentActionResultType::AskUserQuestion(_)) + }); + if finished_asking_question { + ctx.focus(&view.input_view); + } + }); // The input box border color and the footer's shell-mode hint depend // on the input mode. ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| ctx.notify()); diff --git a/crates/warp_tui/src/tui_ask_question_view.rs b/crates/warp_tui/src/tui_ask_question_view.rs new file mode 100644 index 00000000000..991923db02a --- /dev/null +++ b/crates/warp_tui/src/tui_ask_question_view.rs @@ -0,0 +1,634 @@ +//! Interactive TUI card for the `AskUserQuestion` agent tool call. + +use std::collections::HashSet; +use std::time::Duration; + +use warp::tui_export::{ + AIActionStatus, AIAgentActionId, AIAgentActionResultType, AIConversationId, + AskUserQuestionAction, AskUserQuestionAnswerItem, AskUserQuestionEffect, AskUserQuestionItem, + AskUserQuestionPhase, AskUserQuestionResult, AskUserQuestionSession, BlocklistAIActionEvent, + BlocklistAIActionModel, BlocklistAIHistoryModel, OptionFooter, OptionRow, OptionSnapshot, + OptionSourceStatus, +}; +use warpui::SingletonEntity; +use warpui_core::elements::tui::{ + Modifier, TuiChildView, TuiContainer, TuiElement, TuiFlex, TuiParentElement, TuiText, +}; +use warpui_core::keymap::macros::*; +use warpui_core::keymap::{EditableBinding, FixedBinding}; +use warpui_core::r#async::{SpawnedFutureHandle, Timer}; +use warpui_core::{ + AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, +}; + +use crate::keybindings::{is_tui_owned_binding, TUI_BINDING_GROUP}; +use crate::option_selector::{OptionSelectorPage, TuiOptionSelector, TuiOptionSelectorEvent}; +use crate::tui_builder::TuiUiBuilder; + +const ASK_QUESTION_ACTIVE: &str = "TuiAskQuestionActive"; +const AUTO_ADVANCE_DELAY: Duration = Duration::from_millis(300); + +/// Registers controls that must win over the surrounding terminal session +/// while an ask-question card is the active blocker. +pub(crate) fn init(app: &mut AppContext) { + let predicate = id!(TuiAskQuestionView::ui_name()) & id!(ASK_QUESTION_ACTIVE); + app.register_fixed_bindings([FixedBinding::new( + "ctrl-c", + TuiAskQuestionViewAction::SkipAll, + predicate.clone(), + ) + .with_group(TUI_BINDING_GROUP)]); + app.register_editable_bindings([ + EditableBinding::new( + "tui:ask-question:confirm", + "Select or confirm the highlighted answer", + TuiAskQuestionViewAction::Enter, + ) + .with_context_predicate(predicate.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("enter"), + EditableBinding::new( + "tui:ask-question:previous", + "Show the previous question", + TuiAskQuestionViewAction::Previous, + ) + .with_context_predicate(predicate.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("left"), + EditableBinding::new( + "tui:ask-question:next", + "Show the next question", + TuiAskQuestionViewAction::Next, + ) + .with_context_predicate(predicate.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("right"), + EditableBinding::new( + "tui:ask-question:next", + "Show the next question", + TuiAskQuestionViewAction::Next, + ) + .with_context_predicate(predicate) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("tab"), + ]); + app.register_tui_binding_validator::(is_tui_owned_binding); +} + +#[derive(Clone, Debug)] +pub(super) enum TuiAskQuestionViewAction { + Enter, + Previous, + Next, + SkipAll, +} + +pub(super) enum TuiAskQuestionViewEvent { + LayoutChanged, +} + +pub(super) struct TuiAskQuestionView { + action_model: ModelHandle, + conversation_id: AIConversationId, + action_id: AIAgentActionId, + source_questions: Vec, + session: AskUserQuestionSession, + selector: ViewHandle, + auto_advance: Option, + pending_auto_advance_question_index: Option, +} + +impl TuiAskQuestionView { + pub(super) fn new( + action_model: ModelHandle, + conversation_id: AIConversationId, + action_id: AIAgentActionId, + questions: Vec, + ctx: &mut ViewContext, + ) -> Self { + let session = AskUserQuestionSession::new(questions.clone()); + let selector = ctx.add_typed_action_tui_view(TuiOptionSelector::new); + let mut view = Self { + action_model: action_model.clone(), + conversation_id, + action_id, + source_questions: questions, + session, + selector: selector.clone(), + auto_advance: None, + pending_auto_advance_question_index: None, + }; + view.show_current_question(ctx); + + ctx.subscribe_to_view(&selector, |me, _, event, ctx| { + me.handle_selector_event(event, ctx); + }); + ctx.subscribe_to_model(&action_model, |me, _, event, ctx| { + if event.action_id() != &me.action_id { + return; + } + if matches!( + event, + BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) + ) { + ctx.focus(&me.selector); + } + if matches!(event, BlocklistAIActionEvent::FinishedAction { .. }) { + me.abort_auto_advance(); + } + me.invalidate_layout(ctx); + }); + if view.is_waiting_on_answers(ctx) { + ctx.focus(&selector); + } + view + } + + fn is_waiting_on_answers(&self, app: &AppContext) -> bool { + self.action_model + .as_ref(app) + .get_action_status(&self.action_id) + .is_some_and(|status| status.is_blocked()) + } + + pub(super) fn matches_action( + &self, + action_id: &AIAgentActionId, + questions: &[AskUserQuestionItem], + ) -> bool { + &self.action_id == action_id && self.source_questions == questions + } + + /// Restored conversations can predate persisted action results. Match the + /// GUI fallback by treating a missing result as skipped once the owning + /// conversation and all of its actions are terminal. + fn should_restore_as_skipped(&self, app: &AppContext) -> bool { + BlocklistAIHistoryModel::as_ref(app) + .conversation(&self.conversation_id) + .is_some_and(|conversation| !conversation.status().is_in_progress()) + && !self + .action_model + .as_ref(app) + .has_unfinished_actions_for_conversation(self.conversation_id) + } + + fn show_current_question(&mut self, ctx: &mut ViewContext) { + let Some(current) = self.session.current() else { + return; + }; + let rows = current + .question + .multiple_choice_options() + .unwrap_or_default() + .iter() + .enumerate() + .map(|(index, option)| OptionRow { + id: index.to_string(), + label: option.label.clone(), + harness: None, + badge: None, + disabled_reason: None, + }) + .collect(); + let footer = current + .question + .supports_other() + .then(|| OptionFooter::CustomText { + label: "Other…".to_owned(), + }); + let selected_id = current.draft.and_then(|draft| { + draft.other_text.clone().or_else(|| { + draft + .selected_option_indices + .iter() + .min() + .map(usize::to_string) + }) + }); + let snapshot = OptionSnapshot { + rows, + selected_id, + status: OptionSourceStatus::Ready, + footer, + }; + let selected_ids = current + .draft + .map(|draft| { + draft + .selected_option_indices + .iter() + .map(usize::to_string) + .collect::>() + }) + .unwrap_or_default(); + let show_markers = current.question.is_multiselect(); + self.selector.update(ctx, |selector, ctx| { + selector.set_page( + OptionSelectorPage { + header: None, + snapshot, + searchable: false, + }, + ctx, + ); + selector.set_question_state(selected_ids, show_markers, ctx); + }); + self.invalidate_layout(ctx); + } + + fn refresh_selection(&self, ctx: &mut ViewContext) { + let Some(current) = self.session.current() else { + return; + }; + let selected_ids = current + .draft + .map(|draft| { + draft + .selected_option_indices + .iter() + .map(usize::to_string) + .collect() + }) + .unwrap_or_default(); + self.selector.update(ctx, |selector, ctx| { + selector.set_question_state(selected_ids, current.question.is_multiselect(), ctx); + }); + } + + fn handle_selector_event( + &mut self, + event: &TuiOptionSelectorEvent, + ctx: &mut ViewContext, + ) { + match event { + TuiOptionSelectorEvent::Confirmed { id } => { + let Ok(option_index) = id.parse::() else { + return; + }; + self.abort_auto_advance(); + let effect = self + .session + .apply(AskUserQuestionAction::ToggleOption { option_index }); + self.handle_effect(effect, ctx); + } + TuiOptionSelectorEvent::CustomTextSubmitted { value } => { + self.abort_auto_advance(); + let effect = self.session.apply(AskUserQuestionAction::SaveOtherText { + text: Some(value.clone()), + }); + self.handle_effect(effect, ctx); + } + TuiOptionSelectorEvent::CustomTextOpened => { + self.abort_auto_advance(); + let _ = self.session.apply(AskUserQuestionAction::OpenOtherInput); + self.invalidate_layout(ctx); + } + TuiOptionSelectorEvent::LayoutInvalidated => self.invalidate_layout(ctx), + TuiOptionSelectorEvent::RetryRequested | TuiOptionSelectorEvent::Dismissed => {} + } + } + + fn commit_active_other_text(&mut self, ctx: &mut ViewContext) { + let text = self + .selector + .read(ctx, |selector, ctx| selector.active_custom_text(ctx)); + let Some(text) = text else { + return; + }; + let _ = self + .session + .apply(AskUserQuestionAction::SaveOtherText { text: Some(text) }); + } + + fn abort_auto_advance(&mut self) { + self.pending_auto_advance_question_index = None; + if let Some(handle) = self.auto_advance.take() { + handle.abort(); + } + } + + /// Briefly leaves the chosen answer visible before advancing to the next + /// question or submitting the questionnaire. Further input cancels the + /// task, and the captured index prevents a stale task from advancing a + /// different question. + fn schedule_auto_advance(&mut self, ctx: &mut ViewContext) { + let question_index = self.session.current_question_index(); + self.abort_auto_advance(); + self.pending_auto_advance_question_index = Some(question_index); + self.auto_advance = Some(ctx.spawn( + async move { + Timer::after(AUTO_ADVANCE_DELAY).await; + question_index + }, + |me, question_index, ctx| { + me.auto_advance = None; + if me.pending_auto_advance_question_index == Some(question_index) + && me.session.current_question_index() == question_index + { + let effect = me.session.apply(AskUserQuestionAction::Confirm); + me.handle_effect(effect, ctx); + } + }, + )); + } + + fn handle_effect(&mut self, effect: AskUserQuestionEffect, ctx: &mut ViewContext) { + match effect { + AskUserQuestionEffect::Noop => {} + AskUserQuestionEffect::RefreshCurrent => self.refresh_selection(ctx), + AskUserQuestionEffect::FocusOtherInput => { + self.selector + .update(ctx, |selector, ctx| selector.confirm_selected(ctx)); + } + AskUserQuestionEffect::ShowQuestion => self.show_current_question(ctx), + AskUserQuestionEffect::ScheduleAutoAdvance => { + self.refresh_selection(ctx); + self.schedule_auto_advance(ctx); + } + AskUserQuestionEffect::Submit(answers) => self.submit_answers(answers, ctx), + } + self.invalidate_layout(ctx); + } + + fn submit_answers( + &mut self, + answers: Vec, + ctx: &mut ViewContext, + ) { + self.abort_auto_advance(); + let action_id = self.action_id.clone(); + let conversation_id = self.conversation_id; + self.action_model.update(ctx, |action_model, ctx| { + action_model + .ask_user_question_executor(ctx) + .update(ctx, |executor, _| executor.complete(answers.clone())); + action_model.execute_action(&action_id, conversation_id, ctx); + }); + } + + fn invalidate_layout(&self, ctx: &mut ViewContext) { + ctx.emit(TuiAskQuestionViewEvent::LayoutChanged); + ctx.notify(); + } + + fn render_active(&self, app: &AppContext) -> Box { + let builder = TuiUiBuilder::from_app(app); + let Some(current) = self.session.current() else { + return self.render_unavailable(app); + }; + let index = self.session.current_question_index() + 1; + let total = self.session.question_count(); + let position = TuiText::from_spans([ + ("← ".to_owned(), builder.muted_text_style()), + (format!("{index} "), builder.primary_text_style()), + (format!("of {total} "), builder.muted_text_style()), + ("→".to_owned(), builder.muted_text_style()), + ]) + .finish(); + let header = TuiFlex::row() + .child( + TuiText::from_spans([ + ("■ ".to_owned(), builder.attention_glyph_style()), + ( + "Agent questions".to_owned(), + builder.primary_text_style().add_modifier(Modifier::BOLD), + ), + ]) + .finish(), + ) + .flex_child(TuiFlex::row().finish()) + .child(position) + .finish(); + let mut question = current.question.question.clone(); + if current.question.is_multiselect() { + question.push_str(" (select all that apply)"); + } + let body = TuiFlex::column() + .child(header) + .child(TuiText::new(" ").finish()) + .child( + TuiText::new(question) + .with_style(builder.primary_text_style().add_modifier(Modifier::BOLD)) + .finish(), + ) + .child(TuiChildView::new(&self.selector).finish()) + .child(TuiText::new(" ").finish()) + .child( + TuiText::from_spans([ + ("Enter or number ".to_owned(), builder.primary_text_style()), + ("to select ".to_owned(), builder.muted_text_style()), + ("Tab or ← → ".to_owned(), builder.primary_text_style()), + ("to navigate ".to_owned(), builder.muted_text_style()), + ("Ctrl + C ".to_owned(), builder.primary_text_style()), + ("to skip all".to_owned(), builder.muted_text_style()), + ]) + .truncate() + .finish(), + ) + .finish(); + TuiContainer::new(body) + .with_padding(1) + .with_border_style(builder.accent_border_style()) + .with_background(builder.question_surface_background()) + .finish() + } + + fn render_unavailable(&self, app: &AppContext) -> Box { + let builder = TuiUiBuilder::from_app(app); + TuiText::from_spans([ + ("■ ".to_owned(), builder.muted_text_style()), + ( + "Questions unavailable".to_owned(), + builder.muted_text_style(), + ), + ]) + .finish() + } + + fn render_finished_result( + &self, + result: &AskUserQuestionResult, + app: &AppContext, + ) -> Box { + match result { + AskUserQuestionResult::Success { answers } => self.render_answers(answers, app), + AskUserQuestionResult::SkippedByAutoApprove { .. } => { + self.render_summary("Questions skipped due to auto-approve", false, app) + } + AskUserQuestionResult::Error(_) | AskUserQuestionResult::Cancelled => { + self.render_summary("Questions skipped", false, app) + } + } + } + + fn render_answers( + &self, + answers: &[AskUserQuestionAnswerItem], + app: &AppContext, + ) -> Box { + let answered = answers.iter().filter(|answer| !answer.is_skipped()).count(); + let total = answers.len(); + let label = if answered == 0 { + "Questions skipped".to_owned() + } else if answered == total && total == 1 { + "Answered question".to_owned() + } else if answered == total { + format!("Answered all {total} questions") + } else { + format!("Answered {answered} of {total} questions") + }; + let builder = TuiUiBuilder::from_app(app); + let mut content = TuiFlex::column(); + content.add_child(self.render_summary(&label, answered > 0, app)); + for question in self.session.questions() { + let answer = answers + .iter() + .find(|answer| match answer { + AskUserQuestionAnswerItem::Answered { question_id, .. } + | AskUserQuestionAnswerItem::Skipped { question_id } => { + question_id == &question.question_id + } + }) + .map(AskUserQuestionAnswerItem::display_text) + .unwrap_or_else(|| "Skipped".to_owned()); + content.add_child( + TuiContainer::new( + TuiFlex::column() + .child( + TuiText::new(format!("Q: {}", question.question)) + .with_style(builder.primary_text_style()) + .finish(), + ) + .child( + TuiText::new(format!("A: {answer}")) + .with_style(builder.muted_text_style()) + .finish(), + ) + .finish(), + ) + .with_padding_left(2) + .finish(), + ); + } + content.finish() + } + + fn render_summary( + &self, + label: &str, + succeeded: bool, + app: &AppContext, + ) -> Box { + let builder = TuiUiBuilder::from_app(app); + let glyph_style = if succeeded { + builder.success_glyph_style() + } else { + builder.muted_text_style() + }; + TuiText::from_spans([ + (if succeeded { "✓ " } else { "■ " }.to_owned(), glyph_style), + (label.to_owned(), builder.muted_text_style()), + ]) + .finish() + } +} + +#[cfg(test)] +#[path = "tui_ask_question_view_tests.rs"] +mod tests; + +impl Entity for TuiAskQuestionView { + type Event = TuiAskQuestionViewEvent; +} + +impl TuiView for TuiAskQuestionView { + fn ui_name() -> &'static str { + "TuiAskQuestionView" + } + + fn child_view_ids(&self, _app: &AppContext) -> Vec { + vec![self.selector.id()] + } + + fn keymap_context(&self, app: &AppContext) -> warpui_core::keymap::Context { + let mut context = Self::default_keymap_context(); + if self.session.is_editing() + && self.session.current().is_some() + && self.is_waiting_on_answers(app) + { + context.set.insert(ASK_QUESTION_ACTIVE); + } + context + } + + fn render(&self, app: &AppContext) -> Box { + let status = self + .action_model + .as_ref(app) + .get_action_status(&self.action_id); + if let Some(AIActionStatus::Finished(result)) = status.as_ref() { + if let AIAgentActionResultType::AskUserQuestion(result) = &result.result { + return self.render_finished_result(result, app); + } + return self.render_unavailable(app); + } + if status.is_none() && self.should_restore_as_skipped(app) { + return self.render_summary("Questions skipped", false, app); + } + match self.session.phase() { + AskUserQuestionPhase::Completed { answers } => self.render_answers(answers, app), + AskUserQuestionPhase::Editing if self.is_waiting_on_answers(app) => { + self.render_active(app) + } + AskUserQuestionPhase::Editing => { + let builder = TuiUiBuilder::from_app(app); + TuiText::from_spans([ + ("○ ".to_owned(), builder.muted_text_style()), + ("Agent questions".to_owned(), builder.muted_text_style()), + ]) + .finish() + } + } + } +} + +impl TypedActionView for TuiAskQuestionView { + type Action = TuiAskQuestionViewAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + if !self.session.is_editing() || !self.is_waiting_on_answers(ctx) { + return; + } + self.abort_auto_advance(); + match action { + TuiAskQuestionViewAction::Enter => { + let (highlighted_index, active_other_text) = + self.selector.read(ctx, |selector, ctx| { + ( + selector.highlighted_question_index(), + selector.active_custom_text(ctx), + ) + }); + let effect = self.session.apply(AskUserQuestionAction::SubmitAnswer { + highlighted_index, + active_other_text, + }); + self.handle_effect(effect, ctx); + } + TuiAskQuestionViewAction::Previous => { + self.commit_active_other_text(ctx); + let effect = self.session.apply(AskUserQuestionAction::NavigatePrev); + self.handle_effect(effect, ctx); + } + TuiAskQuestionViewAction::Next => { + self.commit_active_other_text(ctx); + let effect = self.session.apply(AskUserQuestionAction::NavigateNext); + self.handle_effect(effect, ctx); + } + TuiAskQuestionViewAction::SkipAll => { + let effect = self.session.apply(AskUserQuestionAction::SkipAll); + self.handle_effect(effect, ctx); + } + } + } +} diff --git a/crates/warp_tui/src/tui_ask_question_view_tests.rs b/crates/warp_tui/src/tui_ask_question_view_tests.rs new file mode 100644 index 00000000000..4d4ae6d8c50 --- /dev/null +++ b/crates/warp_tui/src/tui_ask_question_view_tests.rs @@ -0,0 +1,343 @@ +use warp::tui_export::{ + AIAgentActionId, AIConversationId, Appearance, AskUserQuestionAction, + AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType, +}; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, WindowInvalidation}; +use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, TypedActionView as _, ViewHandle}; + +use super::TuiAskQuestionView; +use crate::option_selector::TuiOptionSelectorAction; +use crate::test_fixtures::add_test_action_model; + +fn question( + id: &str, + text: &str, + is_multiselect: bool, + supports_other: bool, + options: &[&str], +) -> AskUserQuestionItem { + AskUserQuestionItem { + question_id: id.to_owned(), + question: text.to_owned(), + question_type: AskUserQuestionType::MultipleChoice { + is_multiselect, + options: options + .iter() + .map(|label| AskUserQuestionOption { + label: (*label).to_owned(), + recommended: false, + }) + .collect(), + supports_other, + }, + } +} + +fn add_view( + app: &mut App, + questions: Vec, +) -> (warpui::WindowId, ViewHandle) { + app.add_singleton_model(|_| Appearance::mock()); + let action_model = add_test_action_model(app); + app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |ctx| { + TuiAskQuestionView::new( + action_model, + AIConversationId::new(), + AIAgentActionId::from("ask-question".to_owned()), + questions, + ctx, + ) + }, + ) + }) +} + +fn render_active_lines(app: &mut App, view: &ViewHandle) -> Vec { + let mut presenter = TuiPresenter::new(); + app.update(|ctx| { + let mut invalidation = WindowInvalidation::default(); + invalidation.updated.insert(view.as_ref(ctx).selector.id()); + presenter.invalidate(&invalidation, ctx, view.window_id(ctx)); + let element = view.as_ref(ctx).render_active(ctx); + presenter + .present_element(element, TuiRect::new(0, 0, 80, 20), ctx) + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect() + }) +} + +#[test] +fn active_card_matches_question_panel_structure() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![ + question( + "q1", + "Which targets should be tested?", + true, + true, + &["Stable", "Nightly"], + ), + question("q2", "Which shell?", false, false, &["zsh"]), + ], + ); + + let lines = render_active_lines(&mut app, &view); + assert_eq!( + lines, + [ + "┌──────────────────────────────────────────────────────────────────────────────┐", + "│ │", + "│ ■ Agent questions ← 1 of 2 → │", + "│ │", + "│ Which targets should be tested? (select all that apply) │", + "│ (1) Stable │", + "│ (2) Nightly │", + "│ (3) Other… │", + "│ │", + "│ Enter or number to select Tab or ← → to navigate Ctrl + C to skip all │", + "│ │", + "└──────────────────────────────────────────────────────────────────────────────┘", + ] + ); + }); +} + +#[test] +fn completed_answers_match_normalized_questions_by_id() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![ + question("single", "Which shell?", false, false, &["zsh"]), + question( + "multi", + "Which targets?", + true, + false, + &["Stable", "Nightly"], + ), + ], + ); + let answers = vec![ + AskUserQuestionAnswerItem::Answered { + question_id: "single".to_owned(), + selected_options: vec!["zsh".to_owned()], + other_text: String::new(), + }, + AskUserQuestionAnswerItem::Answered { + question_id: "multi".to_owned(), + selected_options: vec!["Stable".to_owned(), "Nightly".to_owned()], + other_text: String::new(), + }, + ]; + let lines = app.read(|ctx| { + let mut presenter = TuiPresenter::new(); + presenter + .present_element( + view.as_ref(ctx).render_answers(&answers, ctx), + TuiRect::new(0, 0, 80, 10), + ctx, + ) + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect::>() + }); + + assert_eq!( + lines, + [ + "✓ Answered all 2 questions", + " Q: Which targets?", + " A: Stable, Nightly", + " Q: Which shell?", + " A: zsh", + ] + ); + }); +} + +#[test] +fn mixed_question_order_matches_the_original_action_payload() { + App::test((), |mut app| async move { + let questions = vec![ + question("single", "Which shell?", false, false, &["zsh"]), + question( + "multi", + "Which targets?", + true, + false, + &["Stable", "Nightly"], + ), + ]; + let (_, view) = add_view(&mut app, questions.clone()); + + app.read(|ctx| { + let view = view.as_ref(ctx); + assert_eq!(view.session.questions()[0].question_id, "multi"); + assert!(view.matches_action( + &AIAgentActionId::from("ask-question".to_owned()), + &questions, + )); + }); + }); +} + +#[test] +fn navigation_restores_multi_selection_and_other_text() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![ + question("q1", "Targets?", true, true, &["Stable", "Nightly"]), + question("q2", "Shell?", false, false, &["zsh"]), + ], + ); + + view.update(&mut app, |view, ctx| { + let effect = view + .session + .apply(AskUserQuestionAction::ToggleOption { option_index: 1 }); + view.handle_effect(effect, ctx); + let effect = view.session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("Canary".to_owned()), + }); + view.handle_effect(effect, ctx); + let effect = view.session.apply(AskUserQuestionAction::NavigateNext); + view.handle_effect(effect, ctx); + let effect = view.session.apply(AskUserQuestionAction::NavigatePrev); + view.handle_effect(effect, ctx); + }); + + let lines = render_active_lines(&mut app, &view); + assert!(lines.iter().any(|line| line.contains("✓ Nightly"))); + assert!(lines.iter().any(|line| line.contains("Canary"))); + }); +} + +#[test] +fn opening_other_keeps_selector_and_shared_session_in_sync() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![question("q1", "Target?", false, true, &["Stable"])], + ); + let selector = app.read(|ctx| view.as_ref(ctx).selector.clone()); + selector.update(&mut app, |selector, ctx| { + selector.handle_action(&TuiOptionSelectorAction::SelectItem(1), ctx); + }); + + app.read(|ctx| { + let view = view.as_ref(ctx); + assert!(view + .session + .current() + .and_then(|current| current.draft) + .is_some_and(|draft| draft.is_other_input_active)); + assert_eq!(view.selector.as_ref(ctx).highlighted_question_index(), None); + }); + }); +} + +#[test] +fn navigating_away_from_a_cleared_other_editor_removes_the_previous_answer() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![ + question("q1", "Targets?", true, true, &["Stable"]), + question("q2", "Shell?", false, false, &["zsh"]), + ], + ); + + view.update(&mut app, |view, ctx| { + let effect = view.session.apply(AskUserQuestionAction::SaveOtherText { + text: Some("Canary".to_owned()), + }); + view.handle_effect(effect, ctx); + view.show_current_question(ctx); + + let effect = view.session.apply(AskUserQuestionAction::OpenOtherInput); + view.handle_effect(effect, ctx); + view.selector.update(ctx, |selector, ctx| { + selector.set_active_custom_text_for_test("", ctx); + }); + + view.commit_active_other_text(ctx); + let effect = view.session.apply(AskUserQuestionAction::NavigateNext); + view.handle_effect(effect, ctx); + }); + + app.read(|ctx| { + let view = view.as_ref(ctx); + assert_eq!(view.session.current_question_index(), 1); + assert!(view.session.draft_for_question(0).is_none()); + }); + }); +} + +#[test] +fn completed_answers_render_deterministic_question_and_answer_rows() { + App::test((), |mut app| async move { + let (_, view) = add_view( + &mut app, + vec![ + question("q1", "Target?", false, false, &["Stable"]), + question("q2", "Shell?", false, false, &["zsh"]), + ], + ); + let answers = vec![ + AskUserQuestionAnswerItem::Answered { + question_id: "q1".to_owned(), + selected_options: vec!["Stable".to_owned()], + other_text: String::new(), + }, + AskUserQuestionAnswerItem::Skipped { + question_id: "q2".to_owned(), + }, + ]; + let lines = app.read(|ctx| { + let mut presenter = TuiPresenter::new(); + presenter + .present_element( + view.as_ref(ctx).render_answers(&answers, ctx), + TuiRect::new(0, 0, 80, 10), + ctx, + ) + .buffer + .to_lines() + .into_iter() + .map(|line| line.trim_end().to_owned()) + .filter(|line| !line.is_empty()) + .collect::>() + }); + + assert_eq!( + lines, + [ + "✓ Answered 1 of 2 questions", + " Q: Target?", + " A: Stable", + " Q: Shell?", + " A: Skipped", + ] + ); + }); +} diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 71d018ae4d0..1def68907f3 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -260,6 +260,16 @@ impl TuiUiBuilder { pub(crate) fn agent_identity_palette(&self) -> Vec { agent_identity_palette(self.warp_theme.terminal_colors()) } + /// Bold cyan option text for the ask-question card. + pub(crate) fn question_option_selected_style(&self) -> TuiStyle { + self.accent_text_style().add_modifier(Modifier::BOLD) + } + + /// Accent-tinted surface behind an interactive ask-question card. + pub(crate) fn question_surface_background(&self) -> Color { + let accent = ThemeFill::from(self.warp_theme.terminal_colors().normal.cyan); + cell_color(self.base_background().blend(&accent.with_opacity(10))) + } /// Collapsible-header style while the pointer hovers it. fn hovered_header_style(&self) -> TuiStyle {