Reuse ChatComposer in request_user_input overlay (#9892)

Reuse the shared chat composer for notes and freeform answers in
request_user_input.

- Build the overlay composer with ChatComposerConfig::plain_text.
- Wire paste-burst flushing + menu surface sizing through the bottom
pane.
This commit is contained in:
Ahmed Ibrahim
2026-01-26 17:21:41 -08:00
committed by GitHub
parent 6a279f6d77
commit 394b967432
11 changed files with 407 additions and 291 deletions

View File

@@ -27,6 +27,22 @@ pub(crate) trait BottomPaneView: Renderable {
false
}
/// Flush any pending paste-burst state. Return true if state changed.
///
/// This lets a modal that reuses `ChatComposer` participate in the same
/// time-based paste burst flushing as the primary composer.
fn flush_paste_burst_if_due(&mut self) -> bool {
false
}
/// Whether the view is currently holding paste-burst transient state.
///
/// When `true`, the bottom pane will schedule a short delayed redraw to
/// give the burst time window a chance to flush.
fn is_in_paste_burst(&self) -> bool {
false
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(

View File

@@ -214,6 +214,20 @@ impl Default for ChatComposerConfig {
}
}
impl ChatComposerConfig {
/// A minimal preset for plain-text inputs embedded in other surfaces.
///
/// This disables popups, slash commands, and image-path attachment behavior
/// so the composer behaves like a simple notes field.
pub(crate) const fn plain_text() -> Self {
Self {
popups_enabled: false,
slash_commands_enabled: false,
image_paste_enabled: false,
}
}
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@@ -669,6 +683,16 @@ impl ChatComposer {
self.sync_popups();
}
/// Update the placeholder text without changing input enablement.
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.placeholder_text = placeholder;
}
/// Move the cursor to the end of the current text buffer.
pub(crate) fn move_cursor_to_end(&mut self) {
self.textarea.set_cursor(self.textarea.text().len());
}
pub(crate) fn clear_for_ctrl_c(&mut self) -> Option<String> {
if self.is_empty() {
return None;

View File

@@ -105,6 +105,7 @@ pub(crate) enum CancellationEvent {
}
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -131,6 +132,8 @@ pub(crate) struct BottomPane {
frame_requester: FrameRequester,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
is_task_running: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
@@ -183,6 +186,8 @@ impl BottomPane {
app_event_tx,
frame_requester,
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
is_task_running: false,
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
@@ -251,19 +256,37 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// If a modal/view is active, handle it here; otherwise forward to composer.
if let Some(view) = self.view_stack.last_mut() {
if key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete()
{
if !self.view_stack.is_empty() {
// We need three pieces of information after routing the key:
// whether Esc completed the view, whether the view finished for any
// reason, and whether a paste-burst timer should be scheduled.
let (ctrl_c_completed, view_complete, view_in_paste_burst) = {
let last_index = self.view_stack.len() - 1;
let view = &mut self.view_stack[last_index];
let ctrl_c_completed = key_event.code == KeyCode::Esc
&& matches!(view.on_ctrl_c(), CancellationEvent::Handled)
&& view.is_complete();
if ctrl_c_completed {
(true, true, false)
} else {
view.handle_key_event(key_event);
(false, view.is_complete(), view.is_in_paste_burst())
}
};
if ctrl_c_completed {
self.view_stack.pop();
self.on_active_view_complete();
} else {
view.handle_key_event(key_event);
if view.is_complete() {
self.view_stack.clear();
self.on_active_view_complete();
if let Some(next_view) = self.view_stack.last()
&& next_view.is_in_paste_burst()
{
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
} else if view_complete {
self.view_stack.clear();
self.on_active_view_complete();
} else if view_in_paste_burst {
self.request_redraw_in(ChatComposer::recommended_paste_flush_delay());
}
self.request_redraw();
InputResult::None
@@ -629,7 +652,13 @@ impl BottomPane {
request
};
let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone());
let modal = RequestUserInputOverlay::new(
request,
self.app_event_tx.clone(),
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
@@ -671,11 +700,23 @@ impl BottomPane {
}
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
// Give the active view the first chance to flush paste-burst state so
// overlays that reuse the composer behave consistently.
if let Some(view) = self.view_stack.last_mut()
&& view.flush_paste_burst_if_due()
{
return true;
}
self.composer.flush_paste_burst_if_due()
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
// A view can hold paste-burst state independently of the primary
// composer, so check it first.
self.view_stack
.last()
.is_some_and(|view| view.is_in_paste_burst())
|| self.composer.is_in_paste_burst()
}
pub(crate) fn on_history_entry_response(

View File

@@ -96,6 +96,13 @@ impl RequestUserInputOverlay {
.saturating_add(notes_title_height)
.saturating_add(notes_input_height);
options_height = remaining_content.saturating_sub(reserved);
if options_height > options_len {
// Expand the notes composer with any leftover rows so we
// do not leave a large blank gap between options and notes.
let extra_rows = options_height.saturating_sub(options_len);
options_height = options_len;
notes_input_height = notes_input_height.saturating_add(extra_rows);
}
}
} else {
let max_notes = remaining.saturating_sub(footer_lines);

View File

@@ -2,13 +2,13 @@
//!
//! Core behaviors:
//! - Each question can be answered by selecting one option and/or providing notes.
//! - When options exist, notes are stored per selected option and appended as extra answers.
//! - Notes are stored per question and appended as extra answers.
//! - Typing while focused on options jumps into notes to keep freeform input fast.
//! - Enter advances to the next question; the last question submits all answers.
//! - Freeform-only questions submit an empty answer list when empty.
use std::cell::RefCell;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -19,18 +19,23 @@ mod render;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::ChatComposerConfig;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::scroll_state::ScrollState;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::render::renderable::Renderable;
use codex_core::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::TextElement;
const NOTES_PLACEHOLDER: &str = "Add notes (optional)";
const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";
// Keep in sync with ChatComposer's minimum composer height.
const MIN_COMPOSER_HEIGHT: u16 = 3;
const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,18 +44,11 @@ enum Focus {
Notes,
}
struct NotesEntry {
text: TextArea,
state: RefCell<TextAreaState>,
}
impl NotesEntry {
fn new() -> Self {
Self {
text: TextArea::new(),
state: RefCell::new(TextAreaState::default()),
}
}
#[derive(Default, Clone)]
struct ComposerDraft {
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
}
struct AnswerState {
@@ -58,10 +56,8 @@ struct AnswerState {
selected: Option<usize>,
// Scrollable cursor state for option navigation/highlight.
option_state: ScrollState,
// Notes for freeform-only questions.
notes: NotesEntry,
// Per-option notes for option questions.
option_notes: Vec<NotesEntry>,
// Per-question notes draft.
draft: ComposerDraft,
}
pub(crate) struct RequestUserInputOverlay {
@@ -69,6 +65,10 @@ pub(crate) struct RequestUserInputOverlay {
request: RequestUserInputEvent,
// Queue of incoming requests to process after the current one.
queue: VecDeque<RequestUserInputEvent>,
// Reuse the shared chat composer so notes/freeform answers match the
// primary input styling and behavior.
composer: ChatComposer,
// One entry per question: selection state plus a stored notes draft.
answers: Vec<AnswerState>,
current_idx: usize,
focus: Focus,
@@ -76,11 +76,30 @@ pub(crate) struct RequestUserInputOverlay {
}
impl RequestUserInputOverlay {
pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self {
pub(crate) fn new(
request: RequestUserInputEvent,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
) -> Self {
// Use the same composer widget, but disable popups/slash-commands and
// image-path attachment so it behaves like a focused notes field.
let mut composer = ChatComposer::new_with_config(
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
ANSWER_PLACEHOLDER.to_string(),
disable_paste_burst,
ChatComposerConfig::plain_text(),
);
// The overlay renders its own footer hints, so keep the composer footer empty.
composer.set_footer_hint_override(Some(Vec::new()));
let mut overlay = Self {
app_event_tx,
request,
queue: VecDeque::new(),
composer,
answers: Vec::new(),
current_idx: 0,
focus: Focus::Options,
@@ -88,6 +107,7 @@ impl RequestUserInputOverlay {
};
overlay.reset_for_request();
overlay.ensure_focus_available();
overlay.restore_current_draft();
overlay
}
@@ -144,28 +164,40 @@ impl RequestUserInputOverlay {
.map(|option| option.label.as_str())
}
fn current_notes_entry(&self) -> Option<&NotesEntry> {
let answer = self.current_answer()?;
if !self.has_options() {
return Some(&answer.notes);
fn capture_composer_draft(&self) -> ComposerDraft {
ComposerDraft {
text: self.composer.current_text_with_pending(),
text_elements: self.composer.text_elements(),
local_image_paths: self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect(),
}
let idx = self
.selected_option_index()
.or(answer.option_state.selected_idx)?;
answer.option_notes.get(idx)
}
fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> {
let has_options = self.has_options();
let answer = self.current_answer_mut()?;
if !has_options {
return Some(&mut answer.notes);
fn save_current_draft(&mut self) {
let draft = self.capture_composer_draft();
if let Some(answer) = self.current_answer_mut() {
answer.draft = draft;
}
let idx = answer
.selected
.or(answer.option_state.selected_idx)
.or_else(|| answer.option_notes.is_empty().then_some(0))?;
answer.option_notes.get_mut(idx)
}
fn restore_current_draft(&mut self) {
self.composer
.set_placeholder_text(self.notes_placeholder().to_string());
self.composer.set_footer_hint_override(Some(Vec::new()));
let Some(answer) = self.current_answer() else {
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
self.composer.move_cursor_to_end();
return;
};
let draft = answer.draft.clone();
self.composer
.set_text_content(draft.text, draft.text_elements, draft.local_image_paths);
self.composer.move_cursor_to_end();
}
fn notes_placeholder(&self) -> &'static str {
@@ -200,24 +232,23 @@ impl RequestUserInputOverlay {
.iter()
.map(|question| {
let mut option_state = ScrollState::new();
let mut option_notes = Vec::new();
if let Some(options) = question.options.as_ref()
&& !options.is_empty()
{
option_state.selected_idx = Some(0);
option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect();
}
AnswerState {
selected: option_state.selected_idx,
option_state,
notes: NotesEntry::new(),
option_notes,
draft: ComposerDraft::default(),
}
})
.collect();
self.current_idx = 0;
self.focus = Focus::Options;
self.composer
.set_text_content(String::new(), Vec::new(), Vec::new());
}
/// Move to the next/previous question, wrapping in either direction.
@@ -226,9 +257,11 @@ impl RequestUserInputOverlay {
if len == 0 {
return;
}
self.save_current_draft();
let offset = if next { 1 } else { len.saturating_sub(1) };
self.current_idx = (self.current_idx + offset) % len;
self.ensure_focus_available();
self.restore_current_draft();
}
/// Synchronize selection state to the currently focused option.
@@ -266,6 +299,7 @@ impl RequestUserInputOverlay {
/// Build the response payload and dispatch it to the app.
fn submit_answers(&mut self) {
self.save_current_draft();
let mut answers = HashMap::new();
for (idx, question) in self.request.questions.iter().enumerate() {
let answer_state = &self.answers[idx];
@@ -278,15 +312,8 @@ impl RequestUserInputOverlay {
} else {
answer_state.selected
};
// Notes are appended as extra answers. When options exist, notes are per selected option.
let notes = if options.is_some_and(|opts| !opts.is_empty()) {
selected_idx
.and_then(|selected| answer_state.option_notes.get(selected))
.map(|entry| entry.text.text().trim().to_string())
.unwrap_or_default()
} else {
answer_state.notes.text.text().trim().to_string()
};
// Notes are appended as extra answers.
let notes = answer_state.draft.text.trim().to_string();
let selected_label = selected_idx.and_then(|selected_idx| {
question
.options
@@ -314,6 +341,7 @@ impl RequestUserInputOverlay {
self.request = next;
self.reset_for_request();
self.ensure_focus_available();
self.restore_current_draft();
} else {
self.done = true;
}
@@ -321,6 +349,7 @@ impl RequestUserInputOverlay {
/// Count freeform-only questions that have no notes.
fn unanswered_count(&self) -> usize {
let current_text = self.composer.current_text();
self.request
.questions
.iter()
@@ -331,7 +360,12 @@ impl RequestUserInputOverlay {
if options.is_some_and(|opts| !opts.is_empty()) {
false
} else {
answer.notes.text.text().trim().is_empty()
let notes = if *idx == self.current_index() {
current_text.as_str()
} else {
answer.draft.text.as_str()
};
notes.trim().is_empty()
}
})
.count()
@@ -339,12 +373,48 @@ impl RequestUserInputOverlay {
/// Compute the preferred notes input height for the current question.
fn notes_input_height(&self, width: u16) -> u16 {
let Some(entry) = self.current_notes_entry() else {
return 3;
};
let usable_width = width.saturating_sub(2);
let text_height = entry.text.desired_height(usable_width).clamp(1, 6);
text_height.saturating_add(2).clamp(3, 8)
let min_height = MIN_COMPOSER_HEIGHT;
self.composer
.desired_height(width.max(1))
.clamp(min_height, min_height.saturating_add(5))
}
fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec<TextElement>) {
let local_image_paths = self
.composer
.local_images()
.into_iter()
.map(|img| img.path)
.collect::<Vec<_>>();
if let Some(answer) = self.current_answer_mut() {
answer.draft = ComposerDraft {
text: text.clone(),
text_elements: text_elements.clone(),
local_image_paths: local_image_paths.clone(),
};
}
self.composer
.set_text_content(text, text_elements, local_image_paths);
self.composer.move_cursor_to_end();
self.composer.set_footer_hint_override(Some(Vec::new()));
}
fn handle_composer_input_result(&mut self, result: InputResult) -> bool {
match result {
InputResult::Submitted {
text,
text_elements,
}
| InputResult::Queued {
text,
text_elements,
} => {
self.apply_submission_to_draft(text, text_elements);
self.go_next_or_submit();
true
}
_ => false,
}
}
}
@@ -376,18 +446,19 @@ impl BottomPaneView for RequestUserInputOverlay {
match self.focus {
Focus::Options => {
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
// Keep selection synchronized as the user moves.
match key_event.code {
KeyCode::Up => {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Down => {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Char(' ') => {
self.select_current_option();
@@ -400,41 +471,43 @@ impl BottomPaneView for RequestUserInputOverlay {
// Any typing while in options switches to notes for fast freeform input.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
let (result, _) = self.composer.handle_key_event(key_event);
self.handle_composer_input_result(result);
}
_ => {}
}
}
Focus::Notes => {
if matches!(key_event.code, KeyCode::Enter) {
self.go_next_or_submit();
self.ensure_selected_for_notes();
let (result, _) = self.composer.handle_key_event(key_event);
if !self.handle_composer_input_result(result) {
self.go_next_or_submit();
}
return;
}
if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) {
let options_len = self.options_len();
let Some(answer) = self.current_answer_mut() else {
return;
};
match key_event.code {
KeyCode::Up => {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_up_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
KeyCode::Down => {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
if let Some(answer) = self.current_answer_mut() {
answer.option_state.move_down_wrap(options_len);
answer.selected = answer.option_state.selected_idx;
}
}
_ => {}
}
return;
}
// Notes are per option when options exist.
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.input(key_event);
}
let (result, _) = self.composer.handle_key_event(key_event);
self.handle_composer_input_result(result);
}
}
}
@@ -453,25 +526,20 @@ impl BottomPaneView for RequestUserInputOverlay {
if pasted.is_empty() {
return false;
}
if matches!(self.focus, Focus::Notes) {
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
if matches!(self.focus, Focus::Options) {
// Treat pastes the same as typing: switch into notes.
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
if let Some(entry) = self.current_notes_entry_mut() {
entry.text.insert_str(&pasted);
return true;
}
return true;
}
false
self.ensure_selected_for_notes();
self.composer.handle_paste(pasted)
}
fn flush_paste_burst_if_due(&mut self) -> bool {
self.composer.flush_paste_burst_if_due()
}
fn is_in_paste_burst(&self) -> bool {
self.composer.is_in_paste_burst()
}
fn try_consume_user_input_request(
@@ -571,6 +639,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "First")]),
tx,
true,
false,
false,
);
overlay.try_consume_user_input_request(request_event(
"turn-2",
@@ -594,6 +665,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
@@ -613,6 +687,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
true,
false,
false,
);
overlay.submit_answers();
@@ -631,6 +708,9 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
{
@@ -639,10 +719,9 @@ mod tests {
}
overlay.select_current_option();
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.insert_str("Notes for option 2");
.composer
.set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.submit_answers();
@@ -660,12 +739,39 @@ mod tests {
);
}
#[test]
fn large_paste_is_preserved_when_switching_questions() {
let (tx, _rx) = test_sender();
let mut overlay = RequestUserInputOverlay::new(
request_event(
"turn-1",
vec![
question_without_options("q1", "First"),
question_without_options("q2", "Second"),
],
),
tx,
true,
false,
false,
);
let large = "x".repeat(1_500);
overlay.composer.handle_paste(large.clone());
overlay.move_question(true);
assert_eq!(overlay.answers[0].draft.text, large);
}
#[test]
fn request_user_input_options_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 64, 16);
insta::assert_snapshot!(
@@ -680,6 +786,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Area")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 60, 8);
insta::assert_snapshot!(
@@ -724,6 +833,9 @@ mod tests {
}],
),
tx,
true,
false,
false,
);
{
let answer = overlay.current_answer_mut().expect("answer missing");
@@ -743,6 +855,9 @@ mod tests {
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 64, 10);
insta::assert_snapshot!(
@@ -757,13 +872,15 @@ mod tests {
let mut overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
tx,
true,
false,
false,
);
overlay.focus = Focus::Notes;
overlay
.current_notes_entry_mut()
.expect("notes entry missing")
.text
.insert_str("Notes");
.composer
.set_text_content("Notes".to_string(), Vec::new(), Vec::new());
overlay.composer.move_cursor_to_end();
overlay.handle_key_event(KeyEvent::from(KeyCode::Down));

View File

@@ -3,12 +3,13 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use crate::bottom_pane::selection_popup_common::GenericDisplayRow;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint;
use crate::render::renderable::Renderable;
@@ -17,18 +18,28 @@ use super::RequestUserInputOverlay;
impl Renderable for RequestUserInputOverlay {
fn desired_height(&self, width: u16) -> u16 {
let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX));
let mut height = sections
.question_lines
.len()
.saturating_add(5)
.saturating_add(self.notes_input_height(width) as usize)
.saturating_add(sections.footer_lines as usize);
let outer = Rect::new(0, 0, width, u16::MAX);
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
let sections = self.layout_sections(Rect::new(0, 0, inner_width, u16::MAX));
let question_height = sections.question_lines.len();
let notes_height = self.notes_input_height(inner_width) as usize;
let footer_height = sections.footer_lines as usize;
// Tight minimum height: progress + header + question + (optional) titles/options
// + notes composer + footer + menu padding.
let mut height = question_height
.saturating_add(notes_height)
.saturating_add(footer_height)
.saturating_add(2); // progress + header
if self.has_options() {
height = height.saturating_add(2);
height = height
.saturating_add(1) // answer title
.saturating_add(self.options_len())
.saturating_add(1); // notes title
}
height = height.max(8);
height as u16
height = height.saturating_add(menu_surface_padding_height() as usize);
height.max(8) as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
@@ -46,7 +57,13 @@ impl RequestUserInputOverlay {
if area.width == 0 || area.height == 0 {
return;
}
let sections = self.layout_sections(area);
// Paint the same menu surface used by other bottom-pane overlays and
// then render the overlay content inside its inset area.
let content_area = render_menu_surface(area, buf);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let sections = self.layout_sections(content_area);
// Progress header keeps the user oriented across multiple questions.
let progress_line = if self.question_count() > 0 {
@@ -177,9 +194,9 @@ impl RequestUserInputOverlay {
);
Paragraph::new(Line::from(warning.dim())).render(
Rect {
x: area.x,
x: content_area.x,
y: footer_y,
width: area.width,
width: content_area.width,
height: 1,
},
buf,
@@ -218,9 +235,9 @@ impl RequestUserInputOverlay {
]);
Paragraph::new(Line::from(hint_spans).dim()).render(
Rect {
x: area.x,
x: content_area.x,
y: hint_y,
width: area.width,
width: content_area.width,
height: 1,
},
buf,
@@ -232,129 +249,24 @@ impl RequestUserInputOverlay {
if !self.focus_is_notes() {
return None;
}
let sections = self.layout_sections(area);
let entry = self.current_notes_entry()?;
let input_area = sections.notes_area;
if input_area.width <= 2 || input_area.height == 0 {
let content_area = menu_surface_inset(area);
if content_area.width == 0 || content_area.height == 0 {
return None;
}
if input_area.height < 3 {
// Inline notes layout uses a prefix and a single-line text area.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if input_area.width <= prefix_width {
return None;
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(prefix_width),
y: input_area.y,
width: input_area.width.saturating_sub(prefix_width),
height: 1,
};
let state = *entry.state.borrow();
return entry.text.cursor_pos_with_state(textarea_rect, state);
let sections = self.layout_sections(content_area);
let input_area = sections.notes_area;
if input_area.width == 0 || input_area.height == 0 {
return None;
}
let text_area_height = input_area.height.saturating_sub(2);
let textarea_rect = Rect {
x: input_area.x.saturating_add(1),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let state = *entry.state.borrow();
entry.text.cursor_pos_with_state(textarea_rect, state)
self.composer.cursor_pos(input_area)
}
/// Render the notes input box or inline notes field.
/// Render the notes composer.
fn render_notes_input(&self, area: Rect, buf: &mut Buffer) {
let Some(entry) = self.current_notes_entry() else {
return;
};
if area.width < 2 || area.height == 0 {
if area.width == 0 || area.height == 0 {
return;
}
if area.height < 3 {
// Inline notes field for tight layouts.
let prefix = notes_prefix();
let prefix_width = prefix.len() as u16;
if area.width <= prefix_width {
Paragraph::new(Line::from(prefix.dim())).render(area, buf);
return;
}
Paragraph::new(Line::from(prefix.dim())).render(
Rect {
x: area.x,
y: area.y,
width: prefix_width,
height: 1,
},
buf,
);
let textarea_rect = Rect {
x: area.x.saturating_add(prefix_width),
y: area.y,
width: area.width.saturating_sub(prefix_width),
height: 1,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim()))
.render(textarea_rect, buf);
}
return;
}
// Draw a light ASCII frame around the notes area.
let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize));
let bottom_border = top_border.clone();
Paragraph::new(Line::from(top_border)).render(
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
buf,
);
Paragraph::new(Line::from(bottom_border)).render(
Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(1)),
width: area.width,
height: 1,
},
buf,
);
for row in 1..area.height.saturating_sub(1) {
Line::from(vec![
"|".into(),
" ".repeat(area.width.saturating_sub(2) as usize).into(),
"|".into(),
])
.render(
Rect {
x: area.x,
y: area.y.saturating_add(row),
width: area.width,
height: 1,
},
buf,
);
}
let text_area_height = area.height.saturating_sub(2);
let textarea_rect = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = entry.state.borrow_mut();
Clear.render(textarea_rect, buf);
StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state);
if entry.text.text().is_empty() {
Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf);
}
self.composer.render(area, buf);
}
fn focus_is_options(&self) -> bool {
@@ -369,7 +281,3 @@ impl RequestUserInputOverlay {
!self.has_options() && self.focus_is_notes()
}
}
fn notes_prefix() -> &'static str {
"Notes: "
}

View File

@@ -2,11 +2,12 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Goal
Share details.
+--------------------------------------------------------------+
|Type your answer (optional) |
+--------------------------------------------------------------+
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt
Question 1/1
Goal
Share details.
Type your answer (optional)
Unanswered: 1 | Will submit as skipped
↑/↓ scroll | enter next question | esc interrupt

View File

@@ -2,19 +2,18 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
Answer
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Question 1/1
Area
Choose an option.
Answer
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes for Option 1 (optional)
Add notes (optional)
Notes for Option 1 (optional)
+--------------------------------------------------------------+
|Add notes (optional) |
+--------------------------------------------------------------+
Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter

View File

@@ -2,13 +2,12 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Next Step
What would you like to do next?
( ) Discuss a code change (Recommended) Walk through a plan and
edit code together.
( ) Run tests Pick a crate and run its
tests.
( ) Review a diff Summarize or review current
Notes: Add notes (optional)
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt
Question 1/1
Next Step
What would you like to do next?
( ) Run tests Pick a crate and run its tests.
( ) Review a diff Summarize or review current changes.
(x) Refactor Tighten structure and remove dead code.
Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt

View File

@@ -2,11 +2,10 @@
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1
Area
Choose an option.
(x) Option 1 First choice.
( ) Option 2 Second choice.
( ) Option 3 Third choice.
Notes: Add notes (optional)
Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter
Question 1/1
Area
Choose an option.
(x) Option 1 First choice.
Option 1 of 3 | ↑/↓ scroll | enter next question | esc i

View File

@@ -42,6 +42,11 @@ pub(crate) fn menu_surface_inset(area: Rect) -> Rect {
area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H))
}
/// Total vertical padding introduced by the menu surface treatment.
pub(crate) const fn menu_surface_padding_height() -> u16 {
MENU_SURFACE_INSET_V * 2
}
/// Paint the shared menu background and return the inset content area.
///
/// This keeps the surface treatment consistent across selection-style overlays