mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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: "
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user