fix: windows can now paste non-ascii multiline text (#8774)

## Summary
This PR builds _heavily_ on the work from @occurrent in #8021 - I've
only added a small fix, added additional tests, and propagated the
changes to tui2.

From the original PR:

> On Windows, Codex relies on PasteBurst for paste detection because
bracketed paste is not reliably available via crossterm.
> 
> When pasted content starts with non-ASCII characters, input is routed
through handle_non_ascii_char, which bypasses the normal paste burst
logic. This change extends the paste burst window for that path, which
should ensure that Enter is correctly grouped as part of the paste.


## Testing
- [x] tested locally cross-platform
- [x] added regression tests

---------

Co-authored-by: occur <occurring@outlook.com>
This commit is contained in:
Dylan Hurd 2026-01-07 23:21:49 -08:00 committed by GitHub
parent 35fd69a9f0
commit 0f8bb4579b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 562 additions and 57 deletions

View file

@ -693,6 +693,42 @@ impl ChatComposer {
if self.paste_burst.try_append_char_if_active(ch, now) {
return (InputResult::None, true);
}
// Non-ASCII input often comes from IMEs and can arrive in quick bursts.
// We do not want to hold the first char (flicker suppression) on this path, but we
// still want to detect paste-like bursts. Before applying any non-ASCII input, flush
// any existing burst buffer (including a pending first char from the ASCII path) so
// we don't carry that transient state forward.
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) {
match decision {
CharDecision::BufferAppend => {
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.textarea.cursor();
let txt = self.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
// If decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
// Otherwise, fall through to normal insertion below.
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
{
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
// seed the paste burst buffer with everything (grabbed + new)
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
}
}
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
@ -1368,9 +1404,8 @@ impl ChatComposer {
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
// misclassified by paste heuristics. Flush any active burst buffer and insert
// non-ASCII characters directly.
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
return self.handle_non_ascii_char(input);
}
@ -1392,7 +1427,6 @@ impl ChatComposer {
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now);
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
@ -2321,8 +2355,7 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "typing should still mark the view dirty");
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "h?");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
@ -2344,14 +2377,18 @@ mod tests {
false,
);
// Force an active paste burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "");
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "hi?there");
assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay);
@ -2560,6 +2597,116 @@ mod tests {
}
}
#[test]
fn non_ascii_char_inserts_immediately_without_burst_state() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
assert_eq!(composer.textarea.text(), "");
assert!(!composer.is_in_paste_burst());
}
// test a variety of non-ascii char sequences to ensure we are handling them correctly
#[test]
fn non_ascii_burst_handles_newline() {
let test_cases = [
// triggers on windows
"天地玄黄 宇宙洪荒
宿
UTF-8
Unicode ",
// Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics.
"你 好\nhi",
];
for test_case in test_cases {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
for c in test_case.chars() {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert!(
composer.textarea.text().is_empty(),
"non-empty textarea before flush: {test_case}",
);
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), test_case);
}
}
#[test]
fn ascii_burst_treats_enter_as_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(result, InputResult::None),
"Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "hi\nthere");
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
@ -2850,6 +2997,11 @@ mod tests {
}
}
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
composer.flush_paste_burst_if_due()
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;
@ -4084,6 +4236,33 @@ mod tests {
assert_eq!(InputResult::Submitted(expected), result);
}
#[test]
fn pending_first_ascii_char_flushes_as_typed() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(composer.is_in_paste_burst());
assert!(composer.textarea.text().is_empty());
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
assert!(flushed, "expected pending first char to flush");
assert_eq!(composer.textarea.text(), "h");
assert!(!composer.is_in_paste_burst());
}
#[test]
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
use crossterm::event::KeyCode;
@ -4118,8 +4297,7 @@ mod tests {
composer.textarea.text().is_empty(),
"text should remain empty until flush"
);
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected buffered text to flush after stop");
assert_eq!(composer.textarea.text(), "a".repeat(count));
assert!(
@ -4152,8 +4330,7 @@ mod tests {
// Nothing should appear until we stop and flush
assert!(composer.textarea.text().is_empty());
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected flush after stopping fast input");
let expected_placeholder = format!("[Pasted Content {count} chars]");

View file

@ -6,6 +6,12 @@ use std::time::Instant;
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);
// Slower paste burts have been observed in windows environments, but ideally
// we want to keep this low
#[cfg(not(windows))]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
#[derive(Default)]
pub(crate) struct PasteBurst {
@ -52,16 +58,14 @@ impl PasteBurst {
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
}
#[cfg(test)]
pub(crate) fn recommended_active_flush_delay() -> Duration {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
}
/// Entry point: decide how to treat a plain char with current timing.
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
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);
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
@ -92,6 +96,40 @@ impl PasteBurst {
CharDecision::RetainFirstChar
}
/// Like on_plain_char(), but never holds the first char.
///
/// Used for non-ASCII input paths (e.g., IMEs) where holding a character can
/// feel like dropped input, while still allowing burst-based paste detection.
///
/// Note: This method will only ever return BufferAppend or BeginBuffer.
pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return Some(CharDecision::BufferAppend);
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return Some(CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
});
}
None
}
fn note_plain_char(&mut self, now: Instant) {
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);
}
/// Flush the buffered burst if the inter-key timeout has elapsed.
///
/// Returns Some(String) when either:
@ -102,9 +140,14 @@ impl PasteBurst {
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timeout = if self.is_active_internal() {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
} else {
PASTE_BURST_CHAR_INTERVAL
};
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
.is_some_and(|t| now.duration_since(t) > timeout);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);

View file

@ -63,9 +63,10 @@ impl TextArea {
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
self.elements.clear();
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
self.wrap_cache.replace(None);
self.preferred_col = None;
self.elements.clear();
self.kill_buffer.clear();
}
@ -735,18 +736,36 @@ impl TextArea {
.position(|e| pos > e.range.start && pos < e.range.end)
}
fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
if pos > self.text.len() {
pos = self.text.len();
fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize {
let pos = pos.min(self.text.len());
if self.text.is_char_boundary(pos) {
return pos;
}
let mut prev = pos;
while prev > 0 && !self.text.is_char_boundary(prev) {
prev -= 1;
}
let mut next = pos;
while next < self.text.len() && !self.text.is_char_boundary(next) {
next += 1;
}
if pos.saturating_sub(prev) <= next.saturating_sub(pos) {
prev
} else {
next
}
}
fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize {
let pos = self.clamp_pos_to_char_boundary(pos);
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
self.clamp_pos_to_char_boundary(e.range.start)
} else {
e.range.end
self.clamp_pos_to_char_boundary(e.range.end)
}
} else {
pos
@ -754,6 +773,7 @@ impl TextArea {
}
fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
let pos = self.clamp_pos_to_char_boundary(pos);
// Do not allow inserting into the middle of an element
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
@ -761,9 +781,9 @@ impl TextArea {
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
self.clamp_pos_to_char_boundary(e.range.start)
} else {
e.range.end
self.clamp_pos_to_char_boundary(e.range.end)
}
} else {
pos
@ -1041,6 +1061,7 @@ impl TextArea {
mod tests {
use super::*;
// crossterm types are intentionally not imported here to avoid unused warnings
use pretty_assertions::assert_eq;
use rand::prelude::*;
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
@ -1133,6 +1154,27 @@ mod tests {
assert_eq!(t.cursor(), 5);
}
#[test]
fn insert_str_at_clamps_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("");
t.set_cursor(0);
t.insert_str_at(1, "A");
assert_eq!(t.text(), "A你");
assert_eq!(t.cursor(), 1);
}
#[test]
fn set_text_clamps_cursor_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("abcd");
t.set_cursor(1);
t.set_text("");
assert_eq!(t.cursor(), 0);
t.insert_str("a");
assert_eq!(t.text(), "a你");
}
#[test]
fn delete_backward_and_forward_edges() {
let mut t = ta_with("abc");

View file

@ -610,6 +610,42 @@ impl ChatComposer {
if self.paste_burst.try_append_char_if_active(ch, now) {
return (InputResult::None, true);
}
// Non-ASCII input often comes from IMEs and can arrive in quick bursts.
// We do not want to hold the first char (flicker suppression) on this path, but we
// still want to detect paste-like bursts. Before applying any non-ASCII input, flush
// any existing burst buffer (including a pending first char from the ASCII path) so
// we don't carry that transient state forward.
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) {
match decision {
CharDecision::BufferAppend => {
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
let cur = self.textarea.cursor();
let txt = self.textarea.text();
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
let before = &txt[..safe_cur];
// If decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
// Otherwise, fall through to normal insertion below.
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
{
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
// seed the paste burst buffer with everything (grabbed + new)
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
}
}
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
@ -1285,9 +1321,8 @@ impl ChatComposer {
{
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
if !has_ctrl_or_alt {
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
// misclassified by paste heuristics. Flush any active burst buffer and insert
// non-ASCII characters directly.
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid
// holding the first char while still allowing burst detection for paste input.
if !ch.is_ascii() {
return self.handle_non_ascii_char(input);
}
@ -1309,7 +1344,6 @@ impl ChatComposer {
if !grab.grabbed.is_empty() {
self.textarea.replace_range(grab.start_byte..safe_cur, "");
}
self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now);
self.paste_burst.append_char_to_buffer(ch, now);
return (InputResult::None, true);
}
@ -1546,7 +1580,8 @@ impl ChatComposer {
let toggles = matches!(key_event.code, KeyCode::Char('?'))
&& !has_ctrl_or_alt(key_event.modifiers)
&& self.is_empty();
&& self.is_empty()
&& !self.is_in_paste_burst();
if !toggles {
return false;
@ -2263,13 +2298,46 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "typing should still mark the view dirty");
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "h?");
assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary);
assert_eq!(composer.footer_mode(), FooterMode::ContextOnly);
}
#[test]
fn question_mark_does_not_toggle_during_paste_burst() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active paste burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(composer.is_in_paste_burst());
assert_eq!(composer.textarea.text(), "");
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected buffered text to flush after stop");
assert_eq!(composer.textarea.text(), "hi?there");
assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay);
}
#[test]
fn shortcut_overlay_persists_while_task_running() {
use crossterm::event::KeyCode;
@ -2473,6 +2541,93 @@ mod tests {
}
}
#[test]
fn non_ascii_burst_handles_newline() {
let test_cases = [
// triggers on windows
"天地玄黄 宇宙洪荒
宿
UTF-8
Unicode ",
// Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics.
"你 好\nhi",
];
for test_case in test_cases {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
for c in test_case.chars() {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert!(
composer.textarea.text().is_empty(),
"non-empty textarea before flush: {test_case}",
);
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), test_case);
}
}
#[test]
fn ascii_burst_treats_enter_as_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Force an active burst so this test doesn't depend on tight timing.
composer
.paste_burst
.begin_with_retro_grabbed(String::new(), Instant::now());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(result, InputResult::None),
"Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let _ = flush_after_paste_burst(&mut composer);
assert_eq!(composer.textarea.text(), "hi\nthere");
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
@ -2775,6 +2930,11 @@ mod tests {
}
}
fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool {
std::thread::sleep(PasteBurst::recommended_active_flush_delay());
composer.flush_paste_burst_if_due()
}
#[test]
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
use crossterm::event::KeyCode;
@ -4002,8 +4162,7 @@ mod tests {
composer.textarea.text().is_empty(),
"text should remain empty until flush"
);
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected buffered text to flush after stop");
assert_eq!(composer.textarea.text(), "a".repeat(count));
assert!(
@ -4036,8 +4195,7 @@ mod tests {
// Nothing should appear until we stop and flush
assert!(composer.textarea.text().is_empty());
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
let flushed = flush_after_paste_burst(&mut composer);
assert!(flushed, "expected flush after stopping fast input");
let expected_placeholder = format!("[Pasted Content {count} chars]");

View file

@ -6,6 +6,12 @@ use std::time::Instant;
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);
// Slower paste burts have been observed in windows environments, but ideally
// we want to keep this low
#[cfg(not(windows))]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
#[cfg(windows)]
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
#[derive(Default)]
pub(crate) struct PasteBurst {
@ -52,16 +58,14 @@ impl PasteBurst {
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
}
#[cfg(test)]
pub(crate) fn recommended_active_flush_delay() -> Duration {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
}
/// Entry point: decide how to treat a plain char with current timing.
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
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);
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
@ -92,6 +96,40 @@ impl PasteBurst {
CharDecision::RetainFirstChar
}
/// Like on_plain_char(), but never holds the first char.
///
/// Used for non-ASCII input paths (e.g., IMEs) where holding a character can
/// feel like dropped input, while still allowing burst-based paste detection.
///
/// Note: This method will only ever return BufferAppend or BeginBuffer.
pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
self.note_plain_char(now);
if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
return Some(CharDecision::BufferAppend);
}
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
return Some(CharDecision::BeginBuffer {
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
});
}
None
}
fn note_plain_char(&mut self, now: Instant) {
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);
}
/// Flush the buffered burst if the inter-key timeout has elapsed.
///
/// Returns Some(String) when either:
@ -102,9 +140,14 @@ impl PasteBurst {
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
let timeout = if self.is_active_internal() {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
} else {
PASTE_BURST_CHAR_INTERVAL
};
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
.is_some_and(|t| now.duration_since(t) > timeout);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);

View file

@ -63,9 +63,10 @@ impl TextArea {
pub fn set_text(&mut self, text: &str) {
self.text = text.to_string();
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
self.elements.clear();
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
self.wrap_cache.replace(None);
self.preferred_col = None;
self.elements.clear();
self.kill_buffer.clear();
}
@ -735,18 +736,36 @@ impl TextArea {
.position(|e| pos > e.range.start && pos < e.range.end)
}
fn clamp_pos_to_nearest_boundary(&self, mut pos: usize) -> usize {
if pos > self.text.len() {
pos = self.text.len();
fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize {
let pos = pos.min(self.text.len());
if self.text.is_char_boundary(pos) {
return pos;
}
let mut prev = pos;
while prev > 0 && !self.text.is_char_boundary(prev) {
prev -= 1;
}
let mut next = pos;
while next < self.text.len() && !self.text.is_char_boundary(next) {
next += 1;
}
if pos.saturating_sub(prev) <= next.saturating_sub(pos) {
prev
} else {
next
}
}
fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize {
let pos = self.clamp_pos_to_char_boundary(pos);
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
self.clamp_pos_to_char_boundary(e.range.start)
} else {
e.range.end
self.clamp_pos_to_char_boundary(e.range.end)
}
} else {
pos
@ -754,6 +773,7 @@ impl TextArea {
}
fn clamp_pos_for_insertion(&self, pos: usize) -> usize {
let pos = self.clamp_pos_to_char_boundary(pos);
// Do not allow inserting into the middle of an element
if let Some(idx) = self.find_element_containing(pos) {
let e = &self.elements[idx];
@ -761,9 +781,9 @@ impl TextArea {
let dist_start = pos.saturating_sub(e.range.start);
let dist_end = e.range.end.saturating_sub(pos);
if dist_start <= dist_end {
e.range.start
self.clamp_pos_to_char_boundary(e.range.start)
} else {
e.range.end
self.clamp_pos_to_char_boundary(e.range.end)
}
} else {
pos
@ -1041,6 +1061,7 @@ impl TextArea {
mod tests {
use super::*;
// crossterm types are intentionally not imported here to avoid unused warnings
use pretty_assertions::assert_eq;
use rand::prelude::*;
fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String {
@ -1133,6 +1154,27 @@ mod tests {
assert_eq!(t.cursor(), 5);
}
#[test]
fn insert_str_at_clamps_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("");
t.set_cursor(0);
t.insert_str_at(1, "A");
assert_eq!(t.text(), "A你");
assert_eq!(t.cursor(), 1);
}
#[test]
fn set_text_clamps_cursor_to_char_boundary() {
let mut t = TextArea::new();
t.insert_str("abcd");
t.set_cursor(1);
t.set_text("");
assert_eq!(t.cursor(), 0);
t.insert_str("a");
assert_eq!(t.text(), "a你");
}
#[test]
fn delete_backward_and_forward_edges() {
let mut t = ta_with("abc");