diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 919866b00c9..b091a8fb0bf 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -587,6 +587,45 @@ 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 + // 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]; + 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"), + } + } + // 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); @@ -1250,9 +1289,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); } @@ -1274,7 +1312,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); } @@ -2164,8 +2201,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); @@ -2374,6 +2410,82 @@ 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)); + + 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 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 handle_paste_small_inserts_text() { use crossterm::event::KeyCode; @@ -2664,6 +2776,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; @@ -3856,8 +3973,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!( @@ -3890,8 +4006,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 49377cb21c5..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,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); 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");