From 868d23f878f951394e52a4626a239aa6c5899bd7 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 25 Jan 2026 21:13:49 -0800 Subject: [PATCH] unify --- .../tui/src/bottom_pane/bottom_pane_view.rs | 18 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 372 +++++++++++++----- codex-rs/tui/src/bottom_pane/mod.rs | 45 ++- .../src/bottom_pane/request_user_input/mod.rs | 147 ++++--- .../bottom_pane/request_user_input/render.rs | 75 +--- ...t__tests__request_user_input_freeform.snap | 6 +- ...ut__tests__request_user_input_options.snap | 6 +- ..._request_user_input_scrolling_options.snap | 2 +- ...ests__request_user_input_tight_height.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 4 +- .../tui/src/public_widgets/composer_input.rs | 4 +- 11 files changed, 439 insertions(+), 242 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 b3be4fed1f..63e1670107 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -27,6 +29,22 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Flush a pending paste-burst when due. Return true if the view modified + /// its state and needs a redraw. + fn flush_paste_burst_if_due(&mut self) -> bool { + false + } + + /// Return true if the view is currently capturing a paste-burst. + fn is_in_paste_burst(&self) -> bool { + false + } + + /// Recommended delay to schedule the next redraw while capturing a burst. + fn recommended_redraw_delay(&self) -> Option { + None + } + /// 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 d1f8d691a2..1a35dcb748 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7,6 +7,7 @@ //! - Handling submit vs newline on Enter. //! - Turning raw key streams into explicit paste operations on platforms where terminals //! don't provide reliable bracketed paste (notably Windows). +//! - Supporting textarea-only embedding by disabling popups/footers and rendering the core input. //! //! # Key Event Routing //! @@ -201,6 +202,7 @@ pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, active_popup: ActivePopup, + popups_enabled: bool, app_event_tx: AppEventSender, history: ChatComposerHistory, quit_shortcut_expires_at: Option, @@ -224,6 +226,8 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + footer_enabled: bool, + prompt_inset_enabled: bool, footer_mode: FooterMode, footer_hint_override: Option>, footer_flash: Option, @@ -253,6 +257,22 @@ enum ActivePopup { const FOOTER_SPACING_HEIGHT: u16 = 0; +pub(crate) fn default_chat_composer( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, +) -> ChatComposer { + ChatComposer::new( + has_input_focus, + app_event_tx, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ) +} + impl ChatComposer { pub fn new( has_input_focus: bool, @@ -267,6 +287,7 @@ impl ChatComposer { textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), active_popup: ActivePopup::None, + popups_enabled: true, app_event_tx, history: ChatComposerHistory::new(), quit_shortcut_expires_at: None, @@ -286,6 +307,8 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + footer_enabled: true, + prompt_inset_enabled: true, footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, footer_flash: None, @@ -320,6 +343,66 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub(crate) fn set_popups_enabled(&mut self, enabled: bool) { + self.popups_enabled = enabled; + if !enabled { + self.active_popup = ActivePopup::None; + } + } + + pub(crate) fn set_footer_enabled(&mut self, enabled: bool) { + self.footer_enabled = enabled; + } + + pub(crate) fn set_prompt_inset_enabled(&mut self, enabled: bool) { + self.prompt_inset_enabled = enabled; + } + + pub(crate) fn desired_textarea_height(&self, width: u16) -> u16 { + let cols_with_margin = if self.prompt_inset_enabled { + LIVE_PREFIX_COLS + 3 + } else { + 4 + }; + self.textarea + .desired_height(width.saturating_sub(cols_with_margin)) + + 2 + } + + pub(crate) fn cursor_pos_textarea_only(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + let textarea_rect = if area.height > 2 && area.width > 2 { + area.inset(Insets::tlbr(1, 1, 1, 1)) + } else { + area + }; + if textarea_rect.is_empty() { + return None; + } + let state = *self.textarea_state.borrow(); + let render_rect = self.textarea_render_rect(textarea_rect); + if render_rect.is_empty() { + return None; + } + self.textarea.cursor_pos_with_state(render_rect, state) + } + + pub(crate) fn render_textarea_only(&self, area: Rect, buf: &mut Buffer) { + let style = user_message_style(); + Block::default().style(style).render_ref(area, buf); + let textarea_rect = if area.height > 2 && area.width > 2 { + area.inset(Insets::tlbr(1, 1, 1, 1)) + } else { + area + }; + if textarea_rect.is_empty() { + return; + } + self.render_textarea(textarea_rect, buf); + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -328,21 +411,29 @@ impl ChatComposer { } fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - let popup_constraint = match &self.active_popup { - ActivePopup::Command(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) + let footer_total_height = if self.footer_enabled { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + footer_hint_height + footer_spacing + } else { + 0 + }; + let popup_constraint = if self.popups_enabled { + match &self.active_popup { + ActivePopup::Command(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } + ActivePopup::None => Constraint::Max(footer_total_height), } - ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::Skill(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) - } - ActivePopup::None => Constraint::Max(footer_total_height), + } else { + Constraint::Max(footer_total_height) }; let [composer_rect, popup_rect] = Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); @@ -350,6 +441,100 @@ impl ChatComposer { [composer_rect, textarea_rect, popup_rect] } + fn render_footer(&self, popup_rect: Rect, buf: &mut Buffer) { + let footer_props = self.footer_props(); + let custom_height = self.custom_footer_height(); + let footer_hint_height = custom_height.unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + let mut left_content_width = None; + if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); + left_content_width = Some(flash.line.width() as u16); + } + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); + left_content_width = Some(footer_hint_items_width(items)); + } else { + render_footer(hint_rect, buf, footer_props); + left_content_width = Some(footer_line_width(footer_props)); + } + render_mode_indicator( + hint_rect, + buf, + self.collaboration_mode_indicator, + !footer_props.is_task_running, + left_content_width, + ); + } + + fn textarea_render_rect(&self, textarea_rect: Rect) -> Rect { + let rect = if self.prompt_inset_enabled && textarea_rect.x < LIVE_PREFIX_COLS { + Rect { + x: textarea_rect.x.saturating_add(LIVE_PREFIX_COLS), + width: textarea_rect.width.saturating_sub(LIVE_PREFIX_COLS), + ..textarea_rect + } + } else { + textarea_rect + }; + if self.prompt_inset_enabled { + rect + } else { + Rect { + x: rect.x.saturating_add(2), + width: rect.width.saturating_sub(2), + ..rect + } + } + } + + fn render_textarea(&self, textarea_rect: Rect, buf: &mut Buffer) { + let render_rect = self.textarea_render_rect(textarea_rect); + if render_rect.is_empty() { + return; + } + let prompt_x = if self.prompt_inset_enabled { + textarea_rect + .x + .checked_sub(LIVE_PREFIX_COLS) + .unwrap_or(textarea_rect.x) + } else { + textarea_rect.x + }; + let prompt = if self.input_enabled { + "›".bold() + } else { + "›".dim() + }; + buf.set_span(prompt_x, textarea_rect.y, &prompt, render_rect.width); + + let mut state = self.textarea_state.borrow_mut(); + StatefulWidgetRef::render_ref(&(&self.textarea), render_rect, buf, &mut state); + if self.textarea.text().is_empty() { + let text = if self.input_enabled { + self.placeholder_text.as_str().to_string() + } else { + self.input_disabled_placeholder + .as_deref() + .unwrap_or("Input disabled.") + .to_string() + }; + let placeholder = Span::from(text).dim(); + Line::from(vec![placeholder]).render_ref(render_rect.inner(Margin::new(0, 0)), buf); + } + } + fn footer_spacing(footer_hint_height: u16) -> u16 { if footer_hint_height == 0 { 0 @@ -1628,7 +1813,9 @@ impl ChatComposer { text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { + if self.popups_enabled + && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) + { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { let is_builtin = @@ -1714,21 +1901,24 @@ impl ChatComposer { // the '/name' token and our caret-based heuristic hides the popup, // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit // literal text. - if let Some(result) = self.try_dispatch_bare_slash_command() { + if self.popups_enabled + && let Some(result) = self.try_dispatch_bare_slash_command() + { return (result, true); } // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); + let in_slash_context = self.popups_enabled + && (matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context @@ -1757,7 +1947,9 @@ impl ChatComposer { .map(|img| img.path.clone()) .collect::>(); let original_pending_pastes = self.pending_pastes.clone(); - if let Some(result) = self.try_dispatch_slash_command_with_args() { + if self.popups_enabled + && let Some(result) = self.try_dispatch_slash_command_with_args() + { return (result, true); } @@ -2176,6 +2368,10 @@ impl ChatComposer { } fn sync_popups(&mut self) { + if !self.popups_enabled { + self.active_popup = ActivePopup::None; + return; + } let file_token = Self::current_at_token(&self.textarea); let browsing_history = self .history @@ -2446,108 +2642,66 @@ impl Renderable for ChatComposer { let [_, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + let render_rect = self.textarea_render_rect(textarea_rect); + if render_rect.is_empty() { + return None; + } + self.textarea.cursor_pos_with_state(render_rect, state) } fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; + let footer_total_height = if self.footer_enabled { + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + footer_hint_height + footer_spacing + } else { + 0 + }; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { + let popup_height = if self.popups_enabled { + match &self.active_popup { ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), ActivePopup::Skill(c) => c.calculate_required_height(width), } + } else { + footer_total_height + }; + self.textarea + .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + + 2 + + popup_height } fn render(&self, area: Rect, buf: &mut Buffer) { let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); - match &self.active_popup { - ActivePopup::Command(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::File(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::Skill(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::None => { - let footer_props = self.footer_props(); - let custom_height = self.custom_footer_height(); - let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - let mut left_content_width = None; - if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { - flash.line.render(inset_footer_hint_area(hint_rect), buf); - left_content_width = Some(flash.line.width() as u16); - } - } else if let Some(items) = self.footer_hint_override.as_ref() { - render_footer_hint_items(hint_rect, buf, items); - left_content_width = Some(footer_hint_items_width(items)); - } else { - render_footer(hint_rect, buf, footer_props); - left_content_width = Some(footer_line_width(footer_props)); + if self.popups_enabled { + match &self.active_popup { + ActivePopup::Command(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::File(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } + ActivePopup::None => { + if self.footer_enabled { + self.render_footer(popup_rect, buf); + } } - render_mode_indicator( - hint_rect, - buf, - self.collaboration_mode_indicator, - !footer_props.is_task_running, - left_content_width, - ); } + } else if self.footer_enabled { + self.render_footer(popup_rect, buf); } let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); - if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - "›".bold() - } else { - "›".dim() - }; - buf.set_span( - textarea_rect.x - LIVE_PREFIX_COLS, - textarea_rect.y, - &prompt, - textarea_rect.width, - ); - } - - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - let text = if self.input_enabled { - self.placeholder_text.as_str().to_string() - } else { - self.input_disabled_placeholder - .as_deref() - .unwrap_or("Input disabled.") - .to_string() - }; - let placeholder = Span::from(text).dim(); - Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); - } + self.render_textarea(textarea_rect, buf); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e772a60215..1cefe4760d 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::InputResult; +pub(crate) use chat_composer::default_chat_composer; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; @@ -130,6 +131,7 @@ pub(crate) struct BottomPane { frame_requester: FrameRequester, has_input_focus: bool, + enhanced_keys_supported: bool, is_task_running: bool, esc_backtrack_hint: bool, animations_enabled: bool, @@ -167,7 +169,7 @@ impl BottomPane { animations_enabled, skills, } = params; - let mut composer = ChatComposer::new( + let mut composer = default_chat_composer( has_input_focus, app_event_tx.clone(), enhanced_keys_supported, @@ -182,6 +184,7 @@ impl BottomPane { app_event_tx, frame_requester, has_input_focus, + enhanced_keys_supported, is_task_running: false, status: None, unified_exec_footer: UnifiedExecFooter::new(), @@ -246,6 +249,7 @@ impl BottomPane { 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() { + let mut paste_burst_delay = None; if key_event.code == KeyCode::Esc && matches!(view.on_ctrl_c(), CancellationEvent::Handled) && view.is_complete() @@ -257,9 +261,14 @@ impl BottomPane { if view.is_complete() { self.view_stack.clear(); self.on_active_view_complete(); + } else if view.is_in_paste_burst() { + paste_burst_delay = view.recommended_redraw_delay(); } } self.request_redraw(); + if let Some(delay) = paste_burst_delay { + self.request_redraw_in(delay); + } InputResult::None } else { // If a task is running and a status line is visible, allow Esc to @@ -320,6 +329,10 @@ impl BottomPane { let needs_redraw = view.handle_paste(pasted); if view.is_complete() { self.on_active_view_complete(); + } else if view.is_in_paste_burst() + && let Some(delay) = view.recommended_redraw_delay() + { + self.request_redraw_in(delay); } if needs_redraw { self.request_redraw(); @@ -329,6 +342,9 @@ impl BottomPane { if needs_redraw { self.request_redraw(); } + if self.composer.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } } } @@ -623,7 +639,11 @@ impl BottomPane { request }; - let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone()); + let modal = RequestUserInputOverlay::new( + request, + self.app_event_tx.clone(), + self.enhanced_keys_supported, + ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( false, @@ -665,11 +685,28 @@ impl BottomPane { } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - self.composer.flush_paste_burst_if_due() + if let Some(view) = self.view_stack.last_mut() { + view.flush_paste_burst_if_due() + } else { + self.composer.flush_paste_burst_if_due() + } } pub(crate) fn is_in_paste_burst(&self) -> bool { - self.composer.is_in_paste_burst() + if let Some(view) = self.view_stack.last() { + view.is_in_paste_burst() + } else { + self.composer.is_in_paste_burst() + } + } + + pub(crate) fn recommended_paste_burst_delay(&self) -> Duration { + if let Some(view) = self.view_stack.last() { + view.recommended_redraw_delay() + .unwrap_or_else(ChatComposer::recommended_paste_flush_delay) + } else { + ChatComposer::recommended_paste_flush_delay() + } } pub(crate) fn on_history_entry_response( 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 a01aa2296b..b648ecdc70 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 @@ -6,23 +6,24 @@ //! - 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::time::Duration; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; mod layout; 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::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::default_chat_composer; use crate::bottom_pane::scroll_state::ScrollState; -use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; use codex_core::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -31,7 +32,6 @@ use codex_protocol::request_user_input::RequestUserInputResponse; const NOTES_PLACEHOLDER: &str = "Add notes (optional)"; const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; -const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Focus { @@ -40,16 +40,27 @@ enum Focus { } struct NotesEntry { - text: TextArea, - state: RefCell, + composer: ChatComposer, } impl NotesEntry { - fn new() -> Self { - Self { - text: TextArea::new(), - state: RefCell::new(TextAreaState::default()), - } + fn new( + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: &'static str, + ) -> Self { + let mut composer = default_chat_composer( + true, + app_event_tx, + enhanced_keys_supported, + placeholder_text.to_string(), + false, + ); + composer.set_popups_enabled(false); + composer.set_footer_enabled(false); + composer.set_prompt_inset_enabled(false); + composer.set_task_running(false); + Self { composer } } } @@ -73,10 +84,15 @@ pub(crate) struct RequestUserInputOverlay { current_idx: usize, focus: Focus, done: bool, + enhanced_keys_supported: bool, } impl RequestUserInputOverlay { - pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + request: RequestUserInputEvent, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + ) -> Self { let mut overlay = Self { app_event_tx, request, @@ -85,6 +101,7 @@ impl RequestUserInputOverlay { current_idx: 0, focus: Focus::Options, done: false, + enhanced_keys_supported, }; overlay.reset_for_request(); overlay.ensure_focus_available(); @@ -168,20 +185,6 @@ impl RequestUserInputOverlay { answer.option_notes.get_mut(idx) } - fn notes_placeholder(&self) -> &'static str { - if self.has_options() - && self - .current_answer() - .is_some_and(|answer| answer.selected.is_none()) - { - SELECT_OPTION_PLACEHOLDER - } else if self.has_options() { - NOTES_PLACEHOLDER - } else { - ANSWER_PLACEHOLDER - } - } - /// Ensure the focus mode is valid for the current question. fn ensure_focus_available(&mut self) { if self.question_count() == 0 { @@ -194,23 +197,44 @@ impl RequestUserInputOverlay { /// Rebuild local answer state from the current request. fn reset_for_request(&mut self) { + let app_event_tx = self.app_event_tx.clone(); + let enhanced_keys_supported = self.enhanced_keys_supported; self.answers = self .request .questions .iter() .map(|question| { let mut option_state = ScrollState::new(); + let mut has_options = false; let mut option_notes = Vec::new(); if let Some(options) = question.options.as_ref() && !options.is_empty() { + has_options = true; option_state.selected_idx = Some(0); - option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect(); + option_notes = (0..options.len()) + .map(|_| { + NotesEntry::new( + app_event_tx.clone(), + enhanced_keys_supported, + NOTES_PLACEHOLDER, + ) + }) + .collect(); } + let placeholder_text = if has_options { + NOTES_PLACEHOLDER + } else { + ANSWER_PLACEHOLDER + }; AnswerState { selected: option_state.selected_idx, option_state, - notes: NotesEntry::new(), + notes: NotesEntry::new( + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + ), option_notes, } }) @@ -282,10 +306,15 @@ impl RequestUserInputOverlay { 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()) + .map(|entry| entry.composer.current_text().trim().to_string()) .unwrap_or_default() } else { - answer_state.notes.text.text().trim().to_string() + answer_state + .notes + .composer + .current_text() + .trim() + .to_string() }; let selected_label = selected_idx.and_then(|selected_idx| { question @@ -331,7 +360,7 @@ impl RequestUserInputOverlay { if options.is_some_and(|opts| !opts.is_empty()) { false } else { - answer.notes.text.text().trim().is_empty() + answer.notes.composer.current_text().trim().is_empty() } }) .count() @@ -342,8 +371,8 @@ impl RequestUserInputOverlay { 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); + let composer_height = entry.composer.desired_textarea_height(width); + let text_height = composer_height.saturating_sub(2).clamp(1, 6); text_height.saturating_add(2).clamp(3, 8) } } @@ -401,14 +430,16 @@ impl BottomPaneView for RequestUserInputOverlay { self.focus = Focus::Notes; self.ensure_selected_for_notes(); if let Some(entry) = self.current_notes_entry_mut() { - entry.text.input(key_event); + entry.composer.handle_key_event(key_event); } } _ => {} } } Focus::Notes => { - if matches!(key_event.code, KeyCode::Enter) { + if matches!(key_event.code, KeyCode::Enter) + && key_event.modifiers == KeyModifiers::NONE + { self.go_next_or_submit(); return; } @@ -433,12 +464,30 @@ impl BottomPaneView for RequestUserInputOverlay { // 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); + entry.composer.handle_key_event(key_event); } } } } + fn flush_paste_burst_if_due(&mut self) -> bool { + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.composer.flush_paste_burst_if_due() + } else { + false + } + } + + fn is_in_paste_burst(&self) -> bool { + self.current_notes_entry() + .is_some_and(|entry| entry.composer.is_in_paste_burst()) + } + + fn recommended_redraw_delay(&self) -> Option { + Some(ChatComposer::recommended_paste_flush_delay()) + } + fn on_ctrl_c(&mut self) -> CancellationEvent { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); self.done = true; @@ -453,21 +502,14 @@ 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; + } + 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 entry.composer.handle_paste(pasted); } return true; } @@ -569,6 +611,7 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "First")]), tx, + true, ); overlay.try_consume_user_input_request(request_event( "turn-2", @@ -592,6 +635,7 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, ); overlay.submit_answers(); @@ -611,6 +655,7 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_without_options("q1", "Notes")]), tx, + true, ); overlay.submit_answers(); @@ -629,6 +674,7 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, ); { @@ -639,7 +685,7 @@ mod tests { overlay .current_notes_entry_mut() .expect("notes entry missing") - .text + .composer .insert_str("Notes for option 2"); overlay.submit_answers(); @@ -664,6 +710,7 @@ mod tests { let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Area")]), tx, + true, ); let area = Rect::new(0, 0, 64, 16); insta::assert_snapshot!( @@ -678,6 +725,7 @@ mod tests { let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Area")]), tx, + true, ); let area = Rect::new(0, 0, 60, 8); insta::assert_snapshot!( @@ -721,6 +769,7 @@ mod tests { }], ), tx, + true, ); { let answer = overlay.current_answer_mut().expect("answer missing"); @@ -740,6 +789,7 @@ mod tests { let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_without_options("q1", "Goal")]), tx, + true, ); let area = Rect::new(0, 0, 64, 10); insta::assert_snapshot!( @@ -754,12 +804,13 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, ); overlay.focus = Focus::Notes; overlay .current_notes_entry_mut() .expect("notes entry missing") - .text + .composer .insert_str("Notes"); 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 68b7eefdc3..e2ccc8df92 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 @@ -5,7 +5,6 @@ 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; @@ -251,18 +250,9 @@ impl RequestUserInputOverlay { 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); + return entry.composer.cursor_pos_textarea_only(textarea_rect); } - 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) + entry.composer.cursor_pos_textarea_only(input_area) } /// Render the notes input box or inline notes field. @@ -296,65 +286,12 @@ impl RequestUserInputOverlay { 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); - } + entry.composer.render_textarea_only(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); - } + Clear.render(area, buf); + entry.composer.render_textarea_only(area, buf); } fn focus_is_options(&self) -> bool { @@ -371,5 +308,5 @@ impl RequestUserInputOverlay { } fn notes_prefix() -> &'static str { - "Notes: " + "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 b698776b13..fa0826d8b4 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 @@ -5,8 +5,8 @@ expression: "render_snapshot(&overlay, area)" Question 1/1 Goal Share details. -+--------------------------------------------------------------+ -|Type your answer (optional) | -+--------------------------------------------------------------+ + + › 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 bc3d7d55b2..82363230ca 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 @@ -14,7 +14,7 @@ Answer Notes for Option 1 (optional) -+--------------------------------------------------------------+ -|Add notes (optional) | -+--------------------------------------------------------------+ + + › Add notes (optional) + Option 1 of 3 | ↑/↓ 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_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 22cb487413..cc01094929 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 @@ -10,5 +10,5 @@ What would you like to do next? ( ) Run tests Pick a crate and run its tests. ( ) Review a diff Summarize or review current -Notes: Add notes (optional) +Notes› Add notes (optional) 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 e1d01df7af..6d713932d2 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 @@ -8,5 +8,5 @@ Choose an option. (x) Option 1 First choice. ( ) Option 2 Second choice. ( ) Option 3 Third choice. -Notes: Add notes (optional) +Notes› Add notes (optional) Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ce87b33eed..7b1d12d019 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2713,9 +2713,7 @@ impl ChatWidget { } else if self.bottom_pane.is_in_paste_burst() { // While capturing a burst, schedule a follow-up tick and skip this frame // to avoid redundant renders between ticks. - frame_requester.schedule_frame_in( - crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), - ); + frame_requester.schedule_frame_in(self.bottom_pane.recommended_paste_burst_delay()); true } else { false diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 46a7e72bcf..ac86bbc9e4 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -13,6 +13,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; +use crate::bottom_pane::default_chat_composer; use crate::render::renderable::Renderable; /// Action returned from feeding a key event into the ComposerInput. @@ -37,7 +38,8 @@ impl ComposerInput { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let sender = AppEventSender::new(tx.clone()); // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. - let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); + let inner = + default_chat_composer(true, sender, true, "Compose new task".to_string(), false); Self { inner, _tx: tx, rx } }