From 394b9674326132258dd3bf8656f400400bf82778 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 17:21:41 -0800 Subject: [PATCH] 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. --- .../tui/src/bottom_pane/bottom_pane_view.rs | 16 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 24 ++ codex-rs/tui/src/bottom_pane/mod.rs | 65 +++- .../bottom_pane/request_user_input/layout.rs | 7 + .../src/bottom_pane/request_user_input/mod.rs | 325 ++++++++++++------ .../bottom_pane/request_user_input/render.rs | 180 +++------- ...t__tests__request_user_input_freeform.snap | 17 +- ...ut__tests__request_user_input_options.snap | 25 +- ..._request_user_input_scrolling_options.snap | 19 +- ...ests__request_user_input_tight_height.snap | 15 +- .../src/bottom_pane/selection_popup_common.rs | 5 + 11 files changed, 407 insertions(+), 291 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index b3be4fed1..d14344922 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ccf0fac5d..c283b10ab 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, @@ -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 { if self.is_empty() { return None; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a435046cb..d0236062f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index fd5066c99..7aa54e953 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -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); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index aaa639177..c710b2b2c 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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, -} - -impl NotesEntry { - fn new() -> Self { - Self { - text: TextArea::new(), - state: RefCell::new(TextAreaState::default()), - } - } +#[derive(Default, Clone)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, } struct AnswerState { @@ -58,10 +56,8 @@ struct AnswerState { selected: Option, // 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, + // 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, + // 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, 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) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + 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)); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 68b7eefdc..368f174b5 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -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: " -} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap index b698776b1..dd5ced6fc 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap index bc3d7d55b..37ed03647 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index 22cb48741..f15d53e11 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index e1d01df7a..7820e0d0f 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 0708bb159..89ef8b3b3 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -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