mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/request_user_input): add interactive picker modal
This commit is contained in:
@@ -896,6 +896,11 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
AppEvent::RequestUserInputAnswer { turn_id, response } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.on_request_user_input_answer(turn_id, response);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoCoordinatorDecision {
|
||||
seq,
|
||||
status,
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::app::ChatWidgetArgs;
|
||||
use crate::chrome_launch::ChromeLaunchOption;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use code_protocol::models::ResponseItem;
|
||||
use code_protocol::request_user_input::RequestUserInputResponse;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender as StdSender;
|
||||
@@ -175,6 +176,12 @@ pub(crate) enum AppEvent {
|
||||
/// bubbling channels through layers of widgets.
|
||||
CodexOp(code_core::protocol::Op),
|
||||
|
||||
/// Submit a response for a pending `request_user_input` tool call.
|
||||
RequestUserInputAnswer {
|
||||
turn_id: String,
|
||||
response: RequestUserInputResponse,
|
||||
},
|
||||
|
||||
AutoCoordinatorDecision {
|
||||
seq: u64,
|
||||
status: AutoCoordinatorStatus,
|
||||
|
||||
@@ -62,6 +62,7 @@ mod update_settings_view;
|
||||
mod undo_timeline_view;
|
||||
mod notifications_settings_view;
|
||||
mod settings_overlay;
|
||||
mod request_user_input_view;
|
||||
pub(crate) use settings_overlay::SettingsSection;
|
||||
pub(crate) mod review_settings_view;
|
||||
pub mod settings_panel;
|
||||
@@ -107,6 +108,7 @@ pub(crate) use mcp_settings_view::McpSettingsView;
|
||||
pub(crate) use theme_selection_view::ThemeSelectionView;
|
||||
use verbosity_selection_view::VerbositySelectionView;
|
||||
pub(crate) use undo_timeline_view::{UndoTimelineEntry, UndoTimelineEntryKind, UndoTimelineView};
|
||||
pub(crate) use request_user_input_view::RequestUserInputView;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ActiveViewKind {
|
||||
@@ -798,6 +800,29 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_request_user_input(&mut self, view: RequestUserInputView) {
|
||||
self.active_view = Some(Box::new(view));
|
||||
self.active_view_kind = ActiveViewKind::Other;
|
||||
self.status_view_active = false;
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn close_request_user_input_view(&mut self) {
|
||||
let should_close = self
|
||||
.active_view
|
||||
.as_ref()
|
||||
.and_then(|view| view.as_any())
|
||||
.is_some_and(|any| any.is::<RequestUserInputView>());
|
||||
if !should_close {
|
||||
return;
|
||||
}
|
||||
|
||||
self.active_view = None;
|
||||
self.active_view_kind = ActiveViewKind::None;
|
||||
self.set_standard_terminal_hint(None);
|
||||
self.status_view_active = false;
|
||||
}
|
||||
|
||||
/// Show a generic list selection popup with items and actions.
|
||||
pub fn show_list_selection(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
|
||||
|
||||
use code_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use code_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use code_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use super::{BottomPane, CancellationEvent};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AnswerState {
|
||||
option_state: ScrollState,
|
||||
freeform: String,
|
||||
}
|
||||
|
||||
pub(crate) struct RequestUserInputView {
|
||||
app_event_tx: AppEventSender,
|
||||
turn_id: String,
|
||||
questions: Vec<RequestUserInputQuestion>,
|
||||
answers: Vec<AnswerState>,
|
||||
current_idx: usize,
|
||||
submitting: bool,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl RequestUserInputView {
|
||||
pub(crate) fn new(
|
||||
turn_id: String,
|
||||
questions: Vec<RequestUserInputQuestion>,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
let answers = questions
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let mut option_state = ScrollState::new();
|
||||
if q
|
||||
.options
|
||||
.as_ref()
|
||||
.is_some_and(|options| !options.is_empty())
|
||||
{
|
||||
option_state.selected_idx = Some(0);
|
||||
}
|
||||
AnswerState {
|
||||
option_state,
|
||||
freeform: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
app_event_tx,
|
||||
turn_id,
|
||||
questions,
|
||||
answers,
|
||||
current_idx: 0,
|
||||
submitting: false,
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn question_count(&self) -> usize {
|
||||
self.questions.len()
|
||||
}
|
||||
|
||||
fn current_question(&self) -> Option<&RequestUserInputQuestion> {
|
||||
self.questions.get(self.current_idx)
|
||||
}
|
||||
|
||||
fn current_answer_mut(&mut self) -> Option<&mut AnswerState> {
|
||||
self.answers.get_mut(self.current_idx)
|
||||
}
|
||||
|
||||
fn current_answer(&self) -> Option<&AnswerState> {
|
||||
self.answers.get(self.current_idx)
|
||||
}
|
||||
|
||||
fn current_options_len(&self) -> usize {
|
||||
self.current_question()
|
||||
.and_then(|q| q.options.as_ref())
|
||||
.map(std::vec::Vec::len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn current_has_options(&self) -> bool {
|
||||
self.current_options_len() > 0
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, up: bool) {
|
||||
let options_len = self.current_options_len();
|
||||
if options_len == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(answer) = self.current_answer_mut() else {
|
||||
return;
|
||||
};
|
||||
if up {
|
||||
answer.option_state.move_up_wrap(options_len);
|
||||
} else {
|
||||
answer.option_state.move_down_wrap(options_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_freeform_char(&mut self, ch: char) {
|
||||
let Some(answer) = self.current_answer_mut() else {
|
||||
return;
|
||||
};
|
||||
answer.freeform.push(ch);
|
||||
}
|
||||
|
||||
fn pop_freeform_char(&mut self) {
|
||||
let Some(answer) = self.current_answer_mut() else {
|
||||
return;
|
||||
};
|
||||
let _ = answer.freeform.pop();
|
||||
}
|
||||
|
||||
fn go_next_or_submit(&mut self) {
|
||||
if self.question_count() == 0 {
|
||||
self.complete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.current_idx + 1 >= self.question_count() {
|
||||
self.submit();
|
||||
} else {
|
||||
self.current_idx = self.current_idx.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) {
|
||||
if self.submitting {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut answers = HashMap::new();
|
||||
for (idx, question) in self.questions.iter().enumerate() {
|
||||
let Some(answer_state) = self.answers.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
let options = question.options.as_ref().filter(|opts| !opts.is_empty());
|
||||
let mut answer_list = Vec::new();
|
||||
|
||||
if let Some(options) = options {
|
||||
let selected_idx = answer_state.option_state.selected_idx;
|
||||
if let Some(label) = selected_idx
|
||||
.and_then(|i| options.get(i))
|
||||
.map(|opt| opt.label.clone())
|
||||
{
|
||||
answer_list.push(label);
|
||||
}
|
||||
} else {
|
||||
let value = answer_state.freeform.trim_end();
|
||||
// Preserve the legacy behavior of `request_user_input` composer replies:
|
||||
// always provide an answer slot, even when empty.
|
||||
answer_list.push(value.to_string());
|
||||
}
|
||||
|
||||
answers.insert(
|
||||
question.id.clone(),
|
||||
RequestUserInputAnswer {
|
||||
answers: answer_list,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.app_event_tx.send(AppEvent::RequestUserInputAnswer {
|
||||
turn_id: self.turn_id.clone(),
|
||||
response: RequestUserInputResponse { answers },
|
||||
});
|
||||
// Keep the picker visible until the ChatWidget consumes the answer.
|
||||
// This prevents a race where the composer becomes active while
|
||||
// `pending_request_user_input` is still set.
|
||||
self.submitting = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for RequestUserInputView {
|
||||
fn as_any(&self) -> Option<&dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) {
|
||||
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.submitting {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
// Close this UI and fall back to the composer for manual input.
|
||||
self.complete = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.go_next_or_submit();
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.current_idx = self.current_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.current_idx =
|
||||
(self.current_idx + 1).min(self.question_count().saturating_sub(1));
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.current_has_options() {
|
||||
self.move_selection(true);
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.current_has_options() {
|
||||
self.move_selection(false);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !self.current_has_options() {
|
||||
self.pop_freeform_char();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
if !self.current_has_options()
|
||||
&& !key_event
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
{
|
||||
self.push_freeform_char(ch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent {
|
||||
if self.submitting {
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, text: String) -> super::bottom_pane_view::ConditionalUpdate {
|
||||
if self.current_has_options() {
|
||||
return super::bottom_pane_view::ConditionalUpdate::NoRedraw;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return super::bottom_pane_view::ConditionalUpdate::NoRedraw;
|
||||
}
|
||||
if let Some(answer) = self.current_answer_mut() {
|
||||
answer.freeform.push_str(&text);
|
||||
}
|
||||
super::bottom_pane_view::ConditionalUpdate::NeedsRedraw
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let inner_width = width.saturating_sub(2);
|
||||
let question = self.current_question();
|
||||
|
||||
let prompt_lines = question
|
||||
.map(|q| {
|
||||
let wrapped = textwrap::wrap(&q.question, inner_width.max(1) as usize);
|
||||
u16::try_from(wrapped.len()).unwrap_or(u16::MAX)
|
||||
})
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 3);
|
||||
|
||||
let options_len = question
|
||||
.and_then(|q| q.options.as_ref())
|
||||
.map(std::vec::Vec::len)
|
||||
.unwrap_or(0);
|
||||
|
||||
let options_lines = if options_len > 0 {
|
||||
u16::try_from(options_len.min(6)).unwrap_or(6).max(2)
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
// Borders (2) + progress (1) + header (1) + prompt (N) + content (M) + footer (1)
|
||||
// + 1 to account for BottomPane's reserved bottom padding line.
|
||||
(2 + 1 + 1 + prompt_lines + options_lines + 1 + 1).max(8)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Clear.render(area, buf);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
|
||||
.title("User input")
|
||||
.title_alignment(Alignment::Center);
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let question_count = self.question_count();
|
||||
let (header, prompt, options) = self
|
||||
.current_question()
|
||||
.map(|q| (
|
||||
q.header.as_str(),
|
||||
q.question.as_str(),
|
||||
q.options.as_ref(),
|
||||
))
|
||||
.unwrap_or(("No questions", "", None));
|
||||
|
||||
let mut y = inner.y;
|
||||
let progress = if question_count > 0 {
|
||||
format!("Question {}/{}", self.current_idx + 1, question_count)
|
||||
} else {
|
||||
"Question 0/0".to_string()
|
||||
};
|
||||
Paragraph::new(Line::from(progress).dim()).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
Paragraph::new(Line::from(header.bold())).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
let footer_height = 1u16;
|
||||
let available = inner
|
||||
.height
|
||||
.saturating_sub((y - inner.y).saturating_add(footer_height));
|
||||
let has_options = options.is_some_and(|opts| !opts.is_empty());
|
||||
let desired_prompt_height = if prompt.trim().is_empty() {
|
||||
0u16
|
||||
} else {
|
||||
let wrapped = textwrap::wrap(prompt, inner.width.max(1) as usize);
|
||||
u16::try_from(wrapped.len()).unwrap_or(u16::MAX).clamp(1, 3)
|
||||
};
|
||||
|
||||
// Always reserve at least 2 rows so option pickers are usable.
|
||||
let (prompt_height, content_height) = if available == 0 {
|
||||
(0, 0)
|
||||
} else if available <= 2 {
|
||||
(0, available)
|
||||
} else {
|
||||
let prompt_budget = available.saturating_sub(2);
|
||||
let prompt_height = desired_prompt_height.min(prompt_budget);
|
||||
let content_height = available.saturating_sub(prompt_height);
|
||||
(prompt_height, content_height)
|
||||
};
|
||||
|
||||
if prompt_height > 0 {
|
||||
let prompt_rect = Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: prompt_height,
|
||||
};
|
||||
Paragraph::new(prompt)
|
||||
.wrap(Wrap { trim: true })
|
||||
.render(prompt_rect, buf);
|
||||
y = y.saturating_add(prompt_height);
|
||||
}
|
||||
|
||||
let content_rect = Rect {
|
||||
x: inner.x,
|
||||
y,
|
||||
width: inner.width,
|
||||
height: content_height,
|
||||
};
|
||||
|
||||
if content_rect.height > 0 {
|
||||
if let Some(options) = options.filter(|opts| !opts.is_empty()) {
|
||||
let state = self
|
||||
.current_answer()
|
||||
.map(|answer| answer.option_state)
|
||||
.unwrap_or_default();
|
||||
let selected = state.selected_idx;
|
||||
let rows = options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
let prefix = if selected.is_some_and(|sel| sel == idx) {
|
||||
"(x)"
|
||||
} else {
|
||||
"( )"
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name: format!("{prefix} {}", opt.label),
|
||||
description: Some(opt.description.clone()),
|
||||
match_indices: None,
|
||||
is_current: false,
|
||||
name_color: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
render_rows(content_rect, buf, &rows, &state, rows.len().max(1), false);
|
||||
} else {
|
||||
let text = self
|
||||
.current_answer()
|
||||
.map(|a| a.freeform.as_str())
|
||||
.unwrap_or("");
|
||||
let placeholder = "Type your answer…";
|
||||
let display = if text.is_empty() {
|
||||
Line::from(placeholder).dim()
|
||||
} else {
|
||||
Line::from(text.to_string())
|
||||
};
|
||||
Paragraph::new(display)
|
||||
.wrap(Wrap { trim: true })
|
||||
.render(content_rect, buf);
|
||||
}
|
||||
}
|
||||
|
||||
let footer_y = inner.y.saturating_add(inner.height).saturating_sub(1);
|
||||
let is_last = question_count > 0 && self.current_idx + 1 >= question_count;
|
||||
let enter_label = if is_last { "submit" } else { "next" };
|
||||
let footer = if has_options {
|
||||
format!(
|
||||
"↑/↓ select | Enter {enter_label} | Esc type in composer | PgUp/PgDn prev/next"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Type answer | Enter {enter_label} | Esc type in composer | PgUp/PgDn prev/next"
|
||||
)
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Span::raw(footer)]).dim()).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: footer_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
+189
-11
@@ -10960,6 +10960,73 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn on_request_user_input_answer(
|
||||
&mut self,
|
||||
turn_id: String,
|
||||
response: code_protocol::request_user_input::RequestUserInputResponse,
|
||||
) {
|
||||
let Some(pending) = self.pending_request_user_input.take() else {
|
||||
tracing::warn!(
|
||||
"[request_user_input] received UI answer but no request is pending (turn_id={turn_id})"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if pending.turn_id != turn_id {
|
||||
tracing::warn!(
|
||||
"[request_user_input] received UI answer for unexpected turn_id (expected={}, got={turn_id})",
|
||||
pending.turn_id,
|
||||
);
|
||||
}
|
||||
|
||||
self.bottom_pane.close_request_user_input_view();
|
||||
|
||||
let display_text = {
|
||||
let mut lines = Vec::new();
|
||||
for question in &pending.questions {
|
||||
let answer: &[String] = response
|
||||
.answers
|
||||
.get(&question.id)
|
||||
.map(|a| a.answers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let value = answer.first().map(String::as_str).unwrap_or("");
|
||||
let value = if value.trim().is_empty() { "(skipped)" } else { value };
|
||||
|
||||
if pending.questions.len() == 1 {
|
||||
lines.push(value.to_string());
|
||||
} else {
|
||||
let header = question.header.trim();
|
||||
if header.is_empty() {
|
||||
lines.push(value.to_string());
|
||||
} else {
|
||||
lines.push(format!("{header}: {value}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
};
|
||||
|
||||
if !display_text.trim().is_empty() {
|
||||
let key = Self::order_key_successor(pending.anchor_key);
|
||||
let state = history_cell::new_user_prompt(display_text);
|
||||
let _ =
|
||||
self.history_insert_plain_state_with_key(state, key, "request_user_input_answer");
|
||||
self.restore_reasoning_in_progress_if_streaming();
|
||||
}
|
||||
|
||||
if let Err(e) = self.code_op_tx.send(Op::UserInputAnswer {
|
||||
id: pending.turn_id,
|
||||
response,
|
||||
}) {
|
||||
tracing::error!("failed to send Op::UserInputAnswer: {e}");
|
||||
}
|
||||
|
||||
self.clear_composer();
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for model".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
if self.layout.scroll_offset > 0 {
|
||||
layout_scroll::to_bottom(self);
|
||||
@@ -13725,7 +13792,14 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("\nReply in the composer to continue.".to_string());
|
||||
let auto_answer = self.auto_state.is_active() && !self.auto_state.is_paused_manual();
|
||||
if auto_answer {
|
||||
lines.push("\nAuto Drive is active; continuing automatically.".to_string());
|
||||
} else {
|
||||
lines.push(
|
||||
"\nUse the picker below to continue (Esc to type in the composer).".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let role = history_cell::plain_role_for_kind(PlainMessageKind::Notice);
|
||||
let state =
|
||||
@@ -13733,16 +13807,120 @@ impl ChatWidget<'_> {
|
||||
let _ = self.history_insert_plain_state_with_key(state, key, "request_user_input");
|
||||
self.restore_reasoning_in_progress_if_streaming();
|
||||
|
||||
self.pending_request_user_input = Some(PendingRequestUserInput {
|
||||
turn_id: ev.turn_id.clone(),
|
||||
call_id: ev.call_id.clone(),
|
||||
anchor_key: key,
|
||||
questions: ev.questions.clone(),
|
||||
});
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for user input".to_string());
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.bottom_pane.ensure_input_focus();
|
||||
if auto_answer {
|
||||
use code_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use code_protocol::request_user_input::RequestUserInputResponse;
|
||||
|
||||
fn choose_option_label(
|
||||
question: &code_protocol::request_user_input::RequestUserInputQuestion,
|
||||
) -> Option<String> {
|
||||
let options = question.options.as_ref()?;
|
||||
if options.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let recommended = options.iter().position(|opt| {
|
||||
opt.label.contains("(Recommended)")
|
||||
|| opt.label.contains("Recommended")
|
||||
|| opt.label.contains("recommended")
|
||||
});
|
||||
let idx = recommended.unwrap_or(0);
|
||||
options.get(idx).map(|opt| opt.label.clone())
|
||||
}
|
||||
|
||||
fn choose_freeform_value(
|
||||
question: &code_protocol::request_user_input::RequestUserInputQuestion,
|
||||
) -> String {
|
||||
let key = format!("{} {}", question.id, question.header).to_ascii_lowercase();
|
||||
if key.contains("confirm") || key.contains("proceed") {
|
||||
"yes".to_string()
|
||||
} else if key.contains("name") {
|
||||
"Auto Drive".to_string()
|
||||
} else {
|
||||
"auto".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
let mut answers = std::collections::HashMap::new();
|
||||
for question in &ev.questions {
|
||||
let answer_value = if let Some(label) = choose_option_label(question) {
|
||||
vec![label]
|
||||
} else {
|
||||
vec![choose_freeform_value(question)]
|
||||
};
|
||||
answers.insert(
|
||||
question.id.clone(),
|
||||
RequestUserInputAnswer {
|
||||
answers: answer_value,
|
||||
},
|
||||
);
|
||||
}
|
||||
let response = RequestUserInputResponse { answers };
|
||||
|
||||
let summary = {
|
||||
let mut parts = Vec::new();
|
||||
for question in &ev.questions {
|
||||
let label = response
|
||||
.answers
|
||||
.get(&question.id)
|
||||
.and_then(|a| a.answers.first())
|
||||
.map(String::as_str)
|
||||
.unwrap_or("(skipped)");
|
||||
if ev.questions.len() == 1 {
|
||||
parts.push(label.to_string());
|
||||
} else {
|
||||
let header = question.header.trim();
|
||||
if header.is_empty() {
|
||||
parts.push(label.to_string());
|
||||
} else {
|
||||
parts.push(format!("{header}: {label}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join("\n")
|
||||
};
|
||||
|
||||
if !summary.trim().is_empty() {
|
||||
let key = Self::order_key_successor(key);
|
||||
let role = history_cell::plain_role_for_kind(PlainMessageKind::Notice);
|
||||
let state = history_cell::plain_message_state_from_paragraphs(
|
||||
PlainMessageKind::Notice,
|
||||
role,
|
||||
vec![format!("Auto Drive answered user input:\n{summary}")],
|
||||
);
|
||||
let _ = self
|
||||
.history_insert_plain_state_with_key(state, key, "request_user_input_auto_answer");
|
||||
self.restore_reasoning_in_progress_if_streaming();
|
||||
}
|
||||
|
||||
if let Err(e) = self.code_op_tx.send(Op::UserInputAnswer {
|
||||
id: ev.turn_id.clone(),
|
||||
response,
|
||||
}) {
|
||||
tracing::error!("failed to send Op::UserInputAnswer: {e}");
|
||||
}
|
||||
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for model".to_string());
|
||||
self.bottom_pane.set_task_running(true);
|
||||
} else {
|
||||
self.pending_request_user_input = Some(PendingRequestUserInput {
|
||||
turn_id: ev.turn_id.clone(),
|
||||
call_id: ev.call_id.clone(),
|
||||
anchor_key: key,
|
||||
questions: ev.questions.clone(),
|
||||
});
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for user input".to_string());
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.bottom_pane.ensure_input_focus();
|
||||
self.bottom_pane
|
||||
.show_request_user_input(crate::bottom_pane::RequestUserInputView::new(
|
||||
ev.turn_id.clone(),
|
||||
ev.questions.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ApplyPatchApprovalRequest(ev) => {
|
||||
|
||||
@@ -35,6 +35,11 @@ use code_tui::test_helpers::{
|
||||
};
|
||||
use code_core::codex::compact::COMPACTION_CHECKPOINT_MESSAGE;
|
||||
use code_protocol::protocol::CompactionCheckpointWarningEvent;
|
||||
use code_protocol::request_user_input::{
|
||||
RequestUserInputEvent,
|
||||
RequestUserInputQuestion,
|
||||
RequestUserInputQuestionOption,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use regex_lite::{Captures, Regex};
|
||||
use serde_json::json;
|
||||
@@ -1431,6 +1436,118 @@ fn tool_activity_showcase() {
|
||||
insta::assert_snapshot!("tool_activity_showcase", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_picker_allows_selection_and_typing() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
let mut event_seq = 0u64;
|
||||
let mut order_seq = 0u64;
|
||||
push_ordered_event(
|
||||
&mut harness,
|
||||
&mut event_seq,
|
||||
&mut order_seq,
|
||||
EventMsg::RequestUserInput(RequestUserInputEvent {
|
||||
call_id: "call-1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
questions: vec![
|
||||
RequestUserInputQuestion {
|
||||
id: "color".into(),
|
||||
header: "Pick One".into(),
|
||||
question: "Select a color:".into(),
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
label: "Blue (Recommended)".into(),
|
||||
description: "Exercises the option picker UI.".into(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
label: "Green".into(),
|
||||
description: "Same flow, different choice.".into(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
RequestUserInputQuestion {
|
||||
id: "display_name".into(),
|
||||
header: "Name".into(),
|
||||
question: "Type a short display name (freeform):".into(),
|
||||
options: None,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 90, 26));
|
||||
assert!(frame.contains("User input"), "request_user_input modal not visible");
|
||||
assert!(
|
||||
frame.contains("(x) Blue (Recommended)"),
|
||||
"options list did not render"
|
||||
);
|
||||
|
||||
harness.send_key(make_key(KeyCode::Down, KeyModifiers::NONE));
|
||||
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 90, 26));
|
||||
assert!(
|
||||
frame.contains("(x) Green"),
|
||||
"Down key did not move selection"
|
||||
);
|
||||
|
||||
harness.send_key(make_key(KeyCode::Enter, KeyModifiers::NONE));
|
||||
harness.send_key(make_key(KeyCode::Char('A'), KeyModifiers::NONE));
|
||||
harness.send_key(make_key(KeyCode::Char('l'), KeyModifiers::NONE));
|
||||
harness.send_key(make_key(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 90, 26));
|
||||
assert!(
|
||||
frame.contains("Ali"),
|
||||
"freeform question did not capture typed input"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_auto_drive_auto_answers_without_modal() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
harness.auto_drive_activate(
|
||||
"Auto Drive user input",
|
||||
true,
|
||||
true,
|
||||
AutoContinueModeFixture::TenSeconds,
|
||||
);
|
||||
|
||||
let mut event_seq = 0u64;
|
||||
let mut order_seq = 0u64;
|
||||
push_ordered_event(
|
||||
&mut harness,
|
||||
&mut event_seq,
|
||||
&mut order_seq,
|
||||
EventMsg::RequestUserInput(RequestUserInputEvent {
|
||||
call_id: "call-2".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
questions: vec![RequestUserInputQuestion {
|
||||
id: "confirm".into(),
|
||||
header: "Confirm".into(),
|
||||
question: "Proceed?".into(),
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
label: "Yes (Recommended)".into(),
|
||||
description: "Return the collected answers.".into(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
label: "No".into(),
|
||||
description: "Lets you see the cancel path.".into(),
|
||||
},
|
||||
]),
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
let frame = normalize_output(render_chat_widget_to_vt100(&mut harness, 90, 24));
|
||||
assert!(
|
||||
frame.contains("Auto Drive answered user input"),
|
||||
"Auto Drive did not auto-resolve request_user_input"
|
||||
);
|
||||
assert!(
|
||||
!frame.contains("User input"),
|
||||
"request_user_input modal should not appear in Auto Drive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_session_grouped_desired_layout() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
|
||||
Reference in New Issue
Block a user