fix(tui/request_user_input): add interactive picker modal

This commit is contained in:
James Peter
2026-01-22 19:17:39 +10:00
parent 95bcf62e66
commit c712325732
6 changed files with 809 additions and 11 deletions
+5
View File
@@ -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,
+7
View File
@@ -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,
+25
View File
@@ -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
View File
@@ -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();