Fix/tui windows multiline paste (#2544)

Introduce a minimal paste-burst heuristic in the chat composer so Enter
is treated as a newline during paste-like bursts (plain chars arriving
in very short intervals), avoiding premature submit after the first line
on Windows consoles that lack bracketed paste.

- Detect tight sequences of plain Char events; open a short window where
Enter inserts a newline instead of submitting.
- Extend the window on newline to handle blank lines in pasted content.
- No behavior change for terminals that already emit Event::Paste; no
OS/env toggles added.
This commit is contained in:
Ahmed Ibrahim
2025-08-22 12:23:58 -07:00
committed by GitHub
parent c5d21a4564
commit cdc77c10fb

View File

@@ -34,6 +34,13 @@ use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
// Heuristic thresholds for detecting paste-like input bursts.
const PASTE_BURST_MIN_CHARS: u16 = 3;
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
@@ -82,6 +89,13 @@ pub(crate) struct ChatComposer {
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
// Heuristic state to detect non-bracketed paste bursts.
last_plain_char_time: Option<Instant>,
consecutive_plain_char_burst: u16,
paste_burst_until: Option<Instant>,
// Buffer to accumulate characters during a detected non-bracketed paste burst.
paste_burst_buffer: String,
in_paste_burst_mode: bool,
}
/// Popup state at most one can be visible at any time.
@@ -115,6 +129,11 @@ impl ChatComposer {
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
last_plain_char_time: None,
consecutive_plain_char_burst: 0,
paste_burst_until: None,
paste_burst_buffer: String::new(),
in_paste_burst_mode: false,
}
}
@@ -202,6 +221,10 @@ impl ChatComposer {
} else {
self.textarea.insert_str(&pasted);
}
// Explicit paste events should not trigger Enter suppression.
self.last_plain_char_time = None;
self.consecutive_plain_char_burst = 0;
self.paste_burst_until = None;
self.sync_command_popup();
self.sync_file_search_popup();
true
@@ -614,6 +637,60 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
..
} => {
// If we're in a paste-like burst capture, treat Enter as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat Enter 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('/');
if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
&& !in_slash_context
{
self.paste_burst_buffer.push('\n');
let now = Instant::now();
// Keep the window alive so subsequent lines are captured too.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// If we have pending placeholder pastes, submit immediately to expand them.
if !self.pending_pastes.is_empty() {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
if text.is_empty() {
return (InputResult::None, true);
}
self.history.record_local_submission(&text);
return (InputResult::Submitted(text), true);
}
// During a paste-like burst, treat Enter as a newline instead of submit.
let now = Instant::now();
let tight_after_char = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL);
let recent_after_char = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW);
let burst_by_count =
recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS;
let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until);
if tight_after_char || burst_by_count || in_burst_window {
self.textarea.insert_str("\n");
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
@@ -645,6 +722,94 @@ impl ChatComposer {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// If we have a buffered non-bracketed paste burst and enough time has
// elapsed since the last char, flush it before handling a new input.
let now = Instant::now();
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) {
let pasted = std::mem::take(&mut self.paste_burst_buffer);
self.in_paste_burst_mode = false;
// Reuse normal paste path (handles large-paste placeholders).
self.handle_paste(pasted);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty())
{
self.paste_burst_buffer.push('\n');
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,
..
} = input
{
let has_ctrl_or_alt =
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
if !has_ctrl_or_alt {
// Update burst heuristics.
match self.last_plain_char_time {
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
self.consecutive_plain_char_burst =
self.consecutive_plain_char_burst.saturating_add(1);
}
_ => {
self.consecutive_plain_char_burst = 1;
}
}
self.last_plain_char_time = Some(now);
// If we're already buffering, capture the char into the buffer.
if self.in_paste_burst_mode {
self.paste_burst_buffer.push(ch);
// Keep the window alive while we receive the burst.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
} else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
// Do not start burst buffering while typing a slash command (first line starts with '/').
let first_line = self.textarea.text().lines().next().unwrap_or("");
if first_line.starts_with('/') {
// Keep heuristics but do not buffer.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
// Insert normally.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
}
// Begin buffering from this character onward.
self.paste_burst_buffer.push(ch);
self.in_paste_burst_mode = true;
// Keep the window alive to continue capturing.
self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return (InputResult::None, true);
}
// Not buffering: insert normally and continue.
self.textarea.input(input);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
return (InputResult::None, true);
} else {
// Modified char ends any burst: flush buffered content before applying.
if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode {
let pasted = std::mem::take(&mut self.paste_burst_buffer);
self.in_paste_burst_mode = false;
self.handle_paste(pasted);
}
}
}
// For non-char inputs (or after flushing), handle normally.
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
@@ -659,6 +824,37 @@ impl ChatComposer {
self.textarea.input(input);
let text_after = self.textarea.text();
// Update paste-burst heuristic for plain Char (no Ctrl/Alt) events.
let crossterm::event::KeyEvent {
code, modifiers, ..
} = input;
match code {
KeyCode::Char(_) => {
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT);
if has_ctrl_or_alt {
// Modified char: clear burst window.
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.paste_burst_until = None;
self.in_paste_burst_mode = false;
self.paste_burst_buffer.clear();
}
// Plain chars handled above.
}
KeyCode::Enter => {
// Keep burst window alive (supports blank lines in paste).
}
_ => {
// Other keys: clear burst window and any buffer (after flushing earlier).
self.consecutive_plain_char_burst = 0;
self.last_plain_char_time = None;
self.paste_burst_until = None;
self.in_paste_burst_mode = false;
// Do not clear paste_burst_buffer here; it should have been flushed above.
}
}
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));