diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index bce9528fd..bb81af747 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -11,7 +11,9 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; use std::cell::RefCell; +use std::time::Instant; +use crate::key_hint::has_ctrl_or_alt; use crate::render::renderable::Renderable; use super::popup_consts::standard_popup_hint_line; @@ -19,6 +21,7 @@ use super::popup_consts::standard_popup_hint_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::ViewCompletion; +use super::paste_burst::PasteBurst; use super::textarea::TextArea; use super::textarea::TextAreaState; @@ -35,6 +38,7 @@ pub(crate) struct CustomPromptView { // UI state textarea: TextArea, textarea_state: RefCell, + paste_burst: PasteBurst, completion: Option, } @@ -59,13 +63,12 @@ impl CustomPromptView { on_submit, textarea, textarea_state: RefCell::new(TextAreaState::default()), + paste_burst: PasteBurst::default(), completion: None, } } -} -impl BottomPaneView for CustomPromptView { - fn handle_key_event(&mut self, key_event: KeyEvent) { + fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) { match key_event { KeyEvent { code: KeyCode::Esc, .. @@ -74,26 +77,58 @@ impl BottomPaneView for CustomPromptView { } KeyEvent { code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, + modifiers, .. } => { - let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { - (self.on_submit)(text); - self.completion = Some(ViewCompletion::Accepted); + if self.paste_burst.direct_insert_newline_should_insert(now) { + self.paste_burst.extend_window(now); + self.textarea.insert_str("\n"); + return; + } + if modifiers == KeyModifiers::NONE { + let text = self.textarea.text().trim().to_string(); + if !text.is_empty() { + (self.on_submit)(text); + self.completion = Some(ViewCompletion::Accepted); + } + } else { + self.textarea.input(key_event); } } KeyEvent { - code: KeyCode::Enter, + code: KeyCode::Char(_), + modifiers, .. - } => { + } if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => { + let paste_like_burst = self.paste_burst.on_plain_char_no_hold(now).is_some(); self.textarea.input(key_event); + if paste_like_burst { + self.paste_burst.extend_window(now); + } + } + KeyEvent { + code: KeyCode::Tab, + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) && self.textarea.allows_paste_burst() => { + let in_paste_burst = self.paste_burst.direct_insert_newline_should_insert(now); + self.textarea.input(key_event); + if in_paste_burst { + self.paste_burst.extend_window(now); + } } other => { self.textarea.input(other); + self.paste_burst.clear_after_explicit_paste(); } } } +} + +impl BottomPaneView for CustomPromptView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + self.handle_key_event_at(key_event, Instant::now()); + } fn on_ctrl_c(&mut self) -> CancellationEvent { self.completion = Some(ViewCompletion::Cancelled); @@ -113,10 +148,15 @@ impl BottomPaneView for CustomPromptView { return false; } self.textarea.insert_str(&pasted); + self.paste_burst.clear_after_explicit_paste(); true } } +#[cfg(test)] +#[path = "custom_prompt_view_tests.rs"] +mod tests; + impl Renderable for CustomPromptView { fn desired_height(&self, width: u16) -> u16 { let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs new file mode 100644 index 000000000..61ab7e6cb --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs @@ -0,0 +1,93 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::sync::mpsc::Receiver; + +#[test] +fn paste_burst_newline_does_not_submit_short_first_line() { + let now = Instant::now(); + + for (first_line, second_line) in [("x", "rest"), ("id", "body"), ("foo", "bar")] { + let (mut view, submitted_rx) = custom_prompt_view(); + let mut ms = 0; + + for ch in first_line.chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(ms)); + ms += 1; + for ch in second_line.chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + + assert!(submitted_rx.try_recv().is_err()); + assert!(!view.is_complete()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 200)); + + assert_eq!( + submitted_rx.try_recv(), + Ok(format!("{first_line}\n{second_line}")) + ); + assert!(view.is_complete()); + } +} + +#[test] +fn paste_burst_newline_after_tab_does_not_submit() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + let mut ms = 0; + + view.handle_key_event_at(KeyEvent::from(KeyCode::Char('x')), now + elapsed(ms)); + ms += 1; + view.handle_key_event_at(KeyEvent::from(KeyCode::Tab), now + elapsed(ms)); + ms += 1; + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(ms)); + ms += 1; + for ch in "rest".chars() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(ms)); + ms += 1; + } + + assert!(submitted_rx.try_recv().is_err()); + assert!(!view.is_complete()); + + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 200)); + + assert_eq!(submitted_rx.try_recv(), Ok("x\nrest".to_string())); + assert!(view.is_complete()); +} + +#[test] +fn delayed_enter_after_typing_submits() { + let (mut view, submitted_rx) = custom_prompt_view(); + let now = Instant::now(); + + for (idx, ch) in "foo".chars().enumerate() { + view.handle_key_event_at(KeyEvent::from(KeyCode::Char(ch)), now + elapsed(idx * 20)); + } + view.handle_key_event_at(KeyEvent::from(KeyCode::Enter), now + elapsed(/*ms*/ 80)); + + assert_eq!(submitted_rx.try_recv(), Ok("foo".to_string())); + assert!(view.is_complete()); +} + +fn custom_prompt_view() -> (CustomPromptView, Receiver) { + let (submitted, submitted_rx) = std::sync::mpsc::channel(); + let view = CustomPromptView::new( + "Edit goal".to_string(), + "Type a goal objective and press Enter".to_string(), + String::new(), + /*context_label*/ None, + Box::new(move |text| { + submitted.send(text).expect("send submitted text"); + }), + ); + (view, submitted_rx) +} + +fn elapsed(ms: usize) -> std::time::Duration { + std::time::Duration::from_millis(ms as u64) +} diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 4ea8310c3..89d8ca73c 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -10,7 +10,7 @@ //! paste once enough chars have arrived. //! //! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain" -//! character events (no Ctrl/Alt) and uses its decisions to either: +//! character events (no Ctrl/Alt) and uses the full buffering decisions to either: //! //! - briefly hold a first ASCII char (flicker suppression), //! - buffer a burst as a single pasted string, or @@ -32,6 +32,10 @@ //! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then //! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a //! previous burst. +//! - Direct-insert callers can skip buffering, use +//! [`PasteBurst::direct_insert_newline_should_insert`] in their Enter handler, and call +//! [`PasteBurst::extend_window`] when Enter or [`PasteBurst::on_plain_char_no_hold`] reports a +//! burst-like stream. //! //! # State Variables //! @@ -106,10 +110,10 @@ //! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since //! holding a non-ASCII character can feel like dropped input. //! -//! # Contract With `ChatComposer` +//! # Contract With Callers //! -//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must -//! interpret decisions and apply the corresponding UI edits: +//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions +//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract: //! //! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. //! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. @@ -333,6 +337,14 @@ impl PasteBurst { self.is_active() || in_burst_window } + /// Decide if Enter should insert a newline for callers that insert chars immediately. + pub fn direct_insert_newline_should_insert(&self, now: Instant) -> bool { + self.newline_should_insert_instead_of_submit(now) + || self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL) + } + /// Keep the burst window alive. pub fn extend_window(&mut self, now: Instant) { self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ade74b890..ae6ebb091 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -333,16 +333,7 @@ async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { "expected marketplace source prompt, got:\n{prompt}" ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + chat.handle_paste("owner/repo".to_string()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match rx.try_recv() {