mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Fix multiline paste in /goal edit (#26047)
Fixes #26025. ## Why `/goal edit` opens `CustomPromptView`, which did not use the paste-burst handling that protects the main composer when terminals deliver paste as rapid key events. On Windows terminals, the first pasted newline could be treated as Enter-to-submit, truncating the goal edit and leaving the rest of the paste behind. ## What This reuses `PasteBurst` in `CustomPromptView` as a lightweight Enter-suppression detector for paste-like key streams. Characters still insert directly, explicit paste still goes through the view paste path, and ordinary text entry still submits on Enter.
This commit is contained in:
committed by
GitHub
Unverified
parent
8030c36970
commit
a2a9e767f7
@@ -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<TextAreaState>,
|
||||
paste_burst: PasteBurst,
|
||||
completion: Option<ViewCompletion>,
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user