Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 125 additions & 10 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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::<AppEvent>();
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::<AppEvent>();
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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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]");
Expand Down
56 changes: 47 additions & 9 deletions codex-rs/tui/src/bottom_pane/paste_burst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<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:
Expand All @@ -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);
Expand Down
58 changes: 50 additions & 8 deletions codex-rs/tui/src/bottom_pane/textarea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -735,35 +736,54 @@ 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
}
}

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];
// Choose closest edge for insertion
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
Loading