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:
Eric Traut
2026-06-03 09:36:50 -07:00
committed by GitHub
Unverified
parent 8030c36970
commit a2a9e767f7
4 changed files with 160 additions and 24 deletions
@@ -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)
}
+16 -4
View File
@@ -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() {