Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ 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;

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;

Expand All @@ -35,6 +38,7 @@ pub(crate) struct CustomPromptView {
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
paste_burst: PasteBurst,
Comment thread
etraut-openai marked this conversation as resolved.
completion: Option<ViewCompletion>,
}

Expand All @@ -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, ..
Expand All @@ -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();
Comment thread
etraut-openai marked this conversation as resolved.
}
}
}
}

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);
Expand All @@ -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 };
Expand Down
93 changes: 93 additions & 0 deletions codex-rs/tui/src/bottom_pane/custom_prompt_view_tests.rs
Original file line number Diff line number Diff line change
@@ -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<String>) {
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)
}
20 changes: 16 additions & 4 deletions codex-rs/tui/src/bottom_pane/paste_burst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
//!
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 1 addition & 10 deletions codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading