From 64ecd2d400a36bafba5e0530d5af7cde335752af Mon Sep 17 00:00:00 2001 From: occur Date: Mon, 15 Dec 2025 02:02:59 +0800 Subject: [PATCH 01/15] fix: windows can now paste non-ascii multiline text --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0d5fe44064d..addabb74ba8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -683,6 +683,7 @@ impl ChatComposer { if self.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } + self.paste_burst.extend_window(now); } if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); @@ -2510,6 +2511,46 @@ mod tests { } } + #[test] + fn non_ascii_start_extends_burst_window_for_enter() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Simulate pasting "你好\nhi" - non-ASCII chars first, then Enter, then ASCII + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); + + // The Enter should be treated as a newline, not a submit + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + matches!(result, InputResult::None), + "Enter after non-ASCII should insert newline, not submit" + ); + + // Continue with more chars + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + + // The text should now contain newline + let text = composer.textarea.text(); + assert!( + text.contains('\n'), + "Text should contain newline: got '{text}'" + ); + } + #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; From feae773ba61f4b1af16abff805325a8d9c467c45 Mon Sep 17 00:00:00 2001 From: occur Date: Mon, 22 Dec 2025 10:11:11 +0800 Subject: [PATCH 02/15] tui: improve paste-burst handling for non-ASCII input --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 85 +++++++++++++++++-- codex-rs/tui/src/bottom_pane/paste_burst.rs | 43 ++++++++-- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index addabb74ba8..44eb051db80 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -683,7 +683,46 @@ impl ChatComposer { if self.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } - self.paste_burst.extend_window(now); + let mut flushed_pending = false; + // 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); + flushed_pending = true; + } + 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 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, ""); + } + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + } + _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), + } + } + // Keep the Enter suppression window alive while a burst is in-flight. If we flushed a + // buffered burst above, handle_paste() clears the window and we should not re-extend it. + if !flushed_pending { + self.paste_burst.extend_window(now); + } } if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); @@ -1347,9 +1386,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); } @@ -1371,7 +1409,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); } @@ -2543,6 +2580,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + // The text should now contain newline let text = composer.textarea.text(); assert!( @@ -2551,6 +2591,41 @@ mod tests { ); } + #[test] + fn burst_paste_fast_non_ascii_prefix_inserts_placeholder_on_flush() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let prefix = "你好".repeat(12); + let suffix = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7); + let paste = format!("{prefix}{suffix}"); + for ch in paste.chars() { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected flush after stopping fast input"); + + let char_count = paste.chars().count(); + let expected_placeholder = format!("[Pasted Content {char_count} chars]"); + assert_eq!(composer.textarea.text(), expected_placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.pending_pastes[0].1, paste); + } + #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 49377cb21c5..8315055ecf6 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -54,14 +54,7 @@ impl PasteBurst { /// 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 +85,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 { + 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: From 77663829cbb58a6fa23ee3f0ffdc3872a05ea76c Mon Sep 17 00:00:00 2001 From: occur Date: Tue, 30 Dec 2025 18:01:30 +0800 Subject: [PATCH 03/15] Fix paste burst handling --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 37 ++++++------ codex-rs/tui/src/bottom_pane/paste_burst.rs | 13 ++++- codex-rs/tui/src/bottom_pane/textarea.rs | 58 ++++++++++++++++--- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 44eb051db80..124dc13ad46 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -704,16 +704,15 @@ impl ChatComposer { let txt = self.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - 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, ""); - } - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); + let start_byte = + super::paste_burst::retro_start_index(before, retro_chars as usize); + let grabbed = before[start_byte..].to_string(); + if !grabbed.is_empty() { + self.textarea.replace_range(start_byte..safe_cur, ""); } + self.paste_burst.begin_with_retro_grabbed(grabbed, now); + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } @@ -2309,8 +2308,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); @@ -2580,8 +2578,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); - let _ = composer.flush_paste_burst_if_due(); + let _ = flush_after_paste_burst(&mut composer); // The text should now contain newline let text = composer.textarea.text(); @@ -2614,8 +2611,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - 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 char_count = paste.chars().count(); @@ -2916,6 +2912,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; @@ -4137,8 +4138,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!( @@ -4171,8 +4171,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]"); diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 8315055ecf6..306a7fda40c 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -6,6 +6,7 @@ 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); +const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); #[derive(Default)] pub(crate) struct PasteBurst { @@ -52,6 +53,11 @@ 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 { self.note_plain_char(now); @@ -129,9 +135,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); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2fd415c7f65..4fc673a11de 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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"); From 40665cbfae6ba01ebbab820e06e4b6f76e558699 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 5 Jan 2026 18:17:43 -0800 Subject: [PATCH 04/15] =?UTF-8?q?Enter=20suppressed=20after=20single=20non?= =?UTF-8?q?=E2=80=91ASCII=20keystroke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 124dc13ad46..78444556931 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -683,7 +683,6 @@ impl ChatComposer { if self.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } - let mut flushed_pending = false; // 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 @@ -691,7 +690,6 @@ impl ChatComposer { // we don't carry that transient state forward. if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); - flushed_pending = true; } if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { match decision { @@ -717,11 +715,6 @@ impl ChatComposer { _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } } - // Keep the Enter suppression window alive while a burst is in-flight. If we flushed a - // buffered burst above, handle_paste() clears the window and we should not re-extend it. - if !flushed_pending { - self.paste_burst.extend_window(now); - } } if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); @@ -2330,14 +2323,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); @@ -2547,7 +2544,7 @@ mod tests { } #[test] - fn non_ascii_start_extends_burst_window_for_enter() { + fn enter_submits_after_single_non_ascii_char() { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -2562,9 +2559,37 @@ mod tests { false, ); - // Simulate pasting "你好\nhi" - non-ASCII chars first, then Enter, then ASCII + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "あ"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn non_ascii_burst_treats_enter_as_newline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Simulate pasting "你好你\nhi" - non-ASCII chars first, then Enter, then ASCII. + // We require enough fast chars to enter burst buffering before suppressing Enter. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); // The Enter should be treated as a newline, not a submit let (result, _) = From f76c623a5e0626503c552c6c73f0458c1f98c03d Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 5 Jan 2026 18:17:56 -0800 Subject: [PATCH 05/15] duplicate changes to tui2 --- .../tui2/src/bottom_pane/chat_composer.rs | 113 +++++++++++++++++- codex-rs/tui2/src/bottom_pane/paste_burst.rs | 56 +++++++-- 2 files changed, 156 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 0073173fdc7..69439e16968 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -600,6 +600,38 @@ 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]; + let start_byte = + super::paste_burst::retro_start_index(before, retro_chars as usize); + let grabbed = before[start_byte..].to_string(); + if !grabbed.is_empty() { + self.textarea.replace_range(start_byte..safe_cur, ""); + } + self.paste_burst.begin_with_retro_grabbed(grabbed, now); + 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); @@ -2423,6 +2455,76 @@ mod tests { } } + #[test] + fn enter_submits_after_single_non_ascii_char() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + 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)); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "あ"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn non_ascii_burst_treats_enter_as_newline() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Simulate pasting "你好你\nhi" - non-ASCII chars first, then Enter, then ASCII. + // We require enough fast chars to enter burst buffering before suppressing Enter. + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + + // The Enter should be treated as a newline, not a submit + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!( + matches!(result, InputResult::None), + "Enter after non-ASCII should insert newline, not submit" + ); + + // Continue with more chars + 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 _ = flush_after_paste_burst(&mut composer); + + // The text should now contain newline + let text = composer.textarea.text(); + assert!( + text.contains('\n'), + "Text should contain newline: got '{text}'" + ); + } + #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; @@ -2725,6 +2827,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; @@ -3905,8 +4012,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!( @@ -3939,8 +4045,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]"); diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs index 49377cb21c5..306a7fda40c 100644 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -6,6 +6,7 @@ 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); +const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); #[derive(Default)] pub(crate) struct PasteBurst { @@ -52,16 +53,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 +91,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 { + 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 +135,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); From 4bb2df20d1eb1bb64a4b811ddbd3042d6ea45aeb Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 5 Jan 2026 22:41:12 -0800 Subject: [PATCH 06/15] add additional tests, fix handle_shortcut_overlay_key --- .../tui2/src/bottom_pane/chat_composer.rs | 155 +++++++++++++++++- codex-rs/tui2/src/bottom_pane/textarea.rs | 58 ++++++- 2 files changed, 204 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 69439e16968..9967e57fa6a 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1556,7 +1556,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; @@ -2252,6 +2253,40 @@ mod tests { 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::(); + 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; @@ -2455,6 +2490,28 @@ 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::(); + 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] fn enter_submits_after_single_non_ascii_char() { use crossterm::event::KeyCode; @@ -2525,6 +2582,75 @@ mod tests { ); } + #[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::(); + 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 non_ascii_appends_to_active_burst_buffer() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + 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 the non-ASCII char takes the fast-path + // (try_append_char_if_active) into the burst buffer. + composer + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + assert!(composer.textarea.text().is_empty()); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.textarea.text(), "1あ"); + } + #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; @@ -3978,6 +4104,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::(); + 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; diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 2fd415c7f65..4fc673a11de 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -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"); From ae15b95d47eb86af7fc3211506a970084d4c872c Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 7 Jan 2026 10:26:18 -0800 Subject: [PATCH 07/15] comment --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 78444556931..b897896c44e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -698,16 +698,19 @@ impl ChatComposer { return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { + // Grab recent 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]; let start_byte = super::paste_burst::retro_start_index(before, retro_chars as usize); + // remove the recent chars we grabbed let grabbed = before[start_byte..].to_string(); if !grabbed.is_empty() { self.textarea.replace_range(start_byte..safe_cur, ""); } + // seed the paste burst buffer with everything (grabbed + new) self.paste_burst.begin_with_retro_grabbed(grabbed, now); self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); From 4bf84b198fb9460cd001e5e4f284b10a7fcd9e20 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 7 Jan 2026 11:35:55 -0800 Subject: [PATCH 08/15] Fix non-ascii paste burst heuristic --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b897896c44e..b089ed4ff8c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -698,22 +698,23 @@ impl ChatComposer { return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { - // Grab recent 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]; - let start_byte = - super::paste_burst::retro_start_index(before, retro_chars as usize); - // remove the recent chars we grabbed - let grabbed = before[start_byte..].to_string(); - if !grabbed.is_empty() { - self.textarea.replace_range(start_byte..safe_cur, ""); + 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); } - // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.begin_with_retro_grabbed(grabbed, now); - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } @@ -2588,11 +2589,11 @@ mod tests { false, ); - // Simulate pasting "你好你\nhi" - non-ASCII chars first, then Enter, then ASCII. + // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. // We require enough fast chars to enter burst buffering before suppressing Enter. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); // The Enter should be treated as a newline, not a submit let (result, _) = From 02280ec19c7209d9170dfa07e5d58fd5810eca4f Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 7 Jan 2026 15:02:11 -0800 Subject: [PATCH 09/15] cleanup --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 ++-- .../tui2/src/bottom_pane/chat_composer.rs | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b089ed4ff8c..75b883f35bc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -702,6 +702,8 @@ impl ChatComposer { 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) @@ -713,8 +715,6 @@ impl ChatComposer { self.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } - // If decide_begin_buffer opted not to start buffering, - // fall through to normal insertion below. } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 9967e57fa6a..e61e9d7c5ae 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -619,15 +619,19 @@ impl ChatComposer { let txt = self.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - let start_byte = - super::paste_burst::retro_start_index(before, retro_chars as usize); - let grabbed = before[start_byte..].to_string(); - if !grabbed.is_empty() { - self.textarea.replace_range(start_byte..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); } - self.paste_burst.begin_with_retro_grabbed(grabbed, now); - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); } _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), } From 1d604f5d8aa1ce5aaa0a881ef5d89893cf834b7a Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 7 Jan 2026 16:44:21 -0800 Subject: [PATCH 10/15] start consolidating --- .../tui2/src/bottom_pane/chat_composer.rs | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index e61e9d7c5ae..69117d8c8c6 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1299,9 +1299,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); } @@ -1323,7 +1322,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); } @@ -2250,8 +2248,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); @@ -2558,11 +2555,11 @@ mod tests { false, ); - // Simulate pasting "你好你\nhi" - non-ASCII chars first, then Enter, then ASCII. + // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. // We require enough fast chars to enter burst buffering before suppressing Enter. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); // The Enter should be treated as a newline, not a submit let (result, _) = @@ -2586,6 +2583,40 @@ mod tests { ); } + #[test] + fn burst_paste_fast_non_ascii_prefix_inserts_placeholder_on_flush() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let prefix = "你好".repeat(12); + let suffix = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7); + let paste = format!("{prefix}{suffix}"); + for ch in paste.chars() { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + let flushed = flush_after_paste_burst(&mut composer); + assert!(flushed, "expected flush after stopping fast input"); + + let char_count = paste.chars().count(); + let expected_placeholder = format!("[Pasted Content {char_count} chars]"); + assert_eq!(composer.textarea.text(), expected_placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.pending_pastes[0].1, paste); + } + #[test] fn ascii_burst_treats_enter_as_newline() { use crossterm::event::KeyCode; From 85acee7851565456546d2720c47d1cc05e805fae Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 7 Jan 2026 16:54:00 -0800 Subject: [PATCH 11/15] synchronize tests --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 75b883f35bc..49b01cb4f5e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2547,6 +2547,28 @@ 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::(); + 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] fn enter_submits_after_single_non_ascii_char() { use crossterm::event::KeyCode; @@ -2651,6 +2673,75 @@ mod tests { assert_eq!(composer.pending_pastes[0].1, paste); } + #[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::(); + 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 non_ascii_appends_to_active_burst_buffer() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + 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 the non-ASCII char takes the fast-path + // (try_append_char_if_active) into the burst buffer. + composer + .paste_burst + .begin_with_retro_grabbed(String::new(), Instant::now()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + + assert!(composer.textarea.text().is_empty()); + let _ = flush_after_paste_burst(&mut composer); + assert_eq!(composer.textarea.text(), "1あ"); + } + #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; @@ -4133,6 +4224,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::(); + 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; From 2082d2bdf156f86e5c4339f0ee68c2ced34aa616 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 7 Jan 2026 17:49:25 -0800 Subject: [PATCH 12/15] add repro case --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 49b01cb4f5e..0cba1e014a0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2728,18 +2728,27 @@ mod tests { false, ); - // Force an active burst so the non-ASCII char takes the fast-path - // (try_append_char_if_active) into the burst buffer. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); + // This string used to trigger early submission when pasted into the composer in powershell + // on Windows + let paste = r#"天地玄黄 宇宙洪荒 +日月盈昃 辰宿列张 +寒来暑往 秋收冬藏 + +你好世界 编码测试 +汉字处理 UTF-8 +终端显示 正确无误 + +风吹竹林 月照大江 +白云千载 青山依旧 +程序员 与 Unicode 同行"#; + + for c in paste.chars() { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } assert!(composer.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "1あ"); + assert_eq!(composer.textarea.text(), paste); } #[test] From 67f0cc0fbe56cc418e21003aeabbd6b374a50076 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 7 Jan 2026 21:03:21 -0800 Subject: [PATCH 13/15] simplified tests --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 134 ++++++----------- codex-rs/tui/src/bottom_pane/paste_burst.rs | 5 + .../tui2/src/bottom_pane/chat_composer.rs | 139 ------------------ 3 files changed, 46 insertions(+), 232 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0cba1e014a0..6712437b2e4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2569,108 +2569,55 @@ mod tests { assert!(!composer.is_in_paste_burst()); } - #[test] - fn enter_submits_after_single_non_ascii_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "あ"), - _ => panic!("expected Submitted"), - } - } - + // test a variety of non-ascii char sequences to ensure we are handling them correctly #[test] fn non_ascii_burst_treats_enter_as_newline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. - // We require enough fast chars to enter burst buffering before suppressing Enter. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); - - // The Enter should be treated as a newline, not a submit - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!( - matches!(result, InputResult::None), - "Enter after non-ASCII should insert newline, not submit" - ); + let test_cases = [ + // triggers on windows + "天地玄黄 宇宙洪荒 +日月盈昃 辰宿列张 +寒来暑往 秋收冬藏 - // Continue with more chars - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); +你好世界 编码测试 +汉字处理 UTF-8 +终端显示 正确无误 - let _ = flush_after_paste_burst(&mut composer); +风吹竹林 月照大江 +白云千载 青山依旧 +程序员 与 Unicode 同行", + // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. + "你 好\nhi", + // singular ascii character then hello + "试\nhello", + ]; - // The text should now contain newline - let text = composer.textarea.text(); - assert!( - text.contains('\n'), - "Text should contain newline: got '{text}'" - ); - } + for test_case in test_cases { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; - #[test] - fn burst_paste_fast_non_ascii_prefix_inserts_placeholder_on_flush() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); - let (tx, _rx) = unbounded_channel::(); - 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)); + } - let prefix = "你好".repeat(12); - let suffix = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7); - let paste = format!("{prefix}{suffix}"); - for ch in paste.chars() { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), 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); } - - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected flush after stopping fast input"); - - let char_count = paste.chars().count(); - let expected_placeholder = format!("[Pasted Content {char_count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1, paste); } #[test] @@ -2712,6 +2659,7 @@ mod tests { assert_eq!(composer.textarea.text(), "hi\nthere"); } + // On windows, ensure we're handling pasting of unicode characters correctly #[test] fn non_ascii_appends_to_active_burst_buffer() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 306a7fda40c..96ed095b8f3 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -6,6 +6,11 @@ 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)] diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 69117d8c8c6..87716032b71 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -2491,54 +2491,6 @@ 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::(); - 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] - fn enter_submits_after_single_non_ascii_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "あ"), - _ => panic!("expected Submitted"), - } - } - #[test] fn non_ascii_burst_treats_enter_as_newline() { use crossterm::event::KeyCode; @@ -2583,40 +2535,6 @@ mod tests { ); } - #[test] - fn burst_paste_fast_non_ascii_prefix_inserts_placeholder_on_flush() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let prefix = "你好".repeat(12); - let suffix = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7); - let paste = format!("{prefix}{suffix}"); - for ch in paste.chars() { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected flush after stopping fast input"); - - let char_count = paste.chars().count(); - let expected_placeholder = format!("[Pasted Content {char_count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1, paste); - } - #[test] fn ascii_burst_treats_enter_as_newline() { use crossterm::event::KeyCode; @@ -2656,36 +2574,6 @@ mod tests { assert_eq!(composer.textarea.text(), "hi\nthere"); } - #[test] - fn non_ascii_appends_to_active_burst_buffer() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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 the non-ASCII char takes the fast-path - // (try_append_char_if_active) into the burst buffer. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "1あ"); - } - #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; @@ -4139,33 +4027,6 @@ 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::(); - 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; From 63741a73a3563fd2515c576dc7213825d1b4ff1c Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 7 Jan 2026 21:21:08 -0800 Subject: [PATCH 14/15] tui2 consistency --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 - .../tui2/src/bottom_pane/chat_composer.rs | 76 ++++++++++--------- codex-rs/tui2/src/bottom_pane/paste_burst.rs | 5 ++ 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6712437b2e4..daa822ed931 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2587,8 +2587,6 @@ mod tests { 程序员 与 Unicode 同行", // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. "你 好\nhi", - // singular ascii character then hello - "试\nhello", ]; for test_case in test_cases { diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 87716032b71..f06996435c1 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -2493,46 +2493,50 @@ mod tests { #[test] fn non_ascii_burst_treats_enter_as_newline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); + let test_cases = [ + // triggers on windows + "天地玄黄 宇宙洪荒 +日月盈昃 辰宿列张 +寒来暑往 秋收冬藏 + +你好世界 编码测试 +汉字处理 UTF-8 +终端显示 正确无误 + +风吹竹林 月照大江 +白云千载 青山依旧 +程序员 与 Unicode 同行", + // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. + "你 好\nhi", + ]; - // Simulate pasting "你 好\nhi" with an ideographic space to trigger pastey heuristics. - // We require enough fast chars to enter burst buffering before suppressing Enter. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); + for test_case in test_cases { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; - // The Enter should be treated as a newline, not a submit - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!( - matches!(result, InputResult::None), - "Enter after non-ASCII should insert newline, not submit" - ); - - // Continue with more chars - 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 (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); - let _ = flush_after_paste_burst(&mut composer); + for c in test_case.chars() { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } - // The text should now contain newline - let text = composer.textarea.text(); - assert!( - text.contains('\n'), - "Text should contain newline: got '{text}'" - ); + 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] diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs index 306a7fda40c..96ed095b8f3 100644 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -6,6 +6,11 @@ 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)] From e44df3e48a2f8016be3bb3a426b14139b9c793c1 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 7 Jan 2026 21:47:56 -0800 Subject: [PATCH 15/15] one more cleanup --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 42 +------------------ .../tui2/src/bottom_pane/chat_composer.rs | 2 +- 2 files changed, 2 insertions(+), 42 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index daa822ed931..1bbc616a97d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2571,7 +2571,7 @@ mod tests { // test a variety of non-ascii char sequences to ensure we are handling them correctly #[test] - fn non_ascii_burst_treats_enter_as_newline() { + fn non_ascii_burst_handles_newline() { let test_cases = [ // triggers on windows "天地玄黄 宇宙洪荒 @@ -2657,46 +2657,6 @@ mod tests { assert_eq!(composer.textarea.text(), "hi\nthere"); } - // On windows, ensure we're handling pasting of unicode characters correctly - #[test] - fn non_ascii_appends_to_active_burst_buffer() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // This string used to trigger early submission when pasted into the composer in powershell - // on Windows - let paste = r#"天地玄黄 宇宙洪荒 -日月盈昃 辰宿列张 -寒来暑往 秋收冬藏 - -你好世界 编码测试 -汉字处理 UTF-8 -终端显示 正确无误 - -风吹竹林 月照大江 -白云千载 青山依旧 -程序员 与 Unicode 同行"#; - - for c in paste.chars() { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), paste); - } - #[test] fn handle_paste_small_inserts_text() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index f06996435c1..6493c8d28ec 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -2492,7 +2492,7 @@ mod tests { } #[test] - fn non_ascii_burst_treats_enter_as_newline() { + fn non_ascii_burst_handles_newline() { let test_cases = [ // triggers on windows "天地玄黄 宇宙洪荒