Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
12 changes: 11 additions & 1 deletion codex-rs/app-server/tests/suite/user_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> {
let codex_home = TempDir::new()?;

let mut mcp = McpProcess::new(codex_home.path()).await?;
// Ensure the child process does not inherit any originator override from
// the parent environment. This test asserts the default originator
// ("codex_cli_rs"), so we explicitly remove the override if present.
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(
codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR,
None,
)],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let request_id = mcp.send_get_user_agent_request().await?;
Expand Down
46 changes: 44 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,9 @@ impl ChatComposer {
} else {
self.textarea.insert_str(&pasted);
}
// Explicit paste events should not trigger Enter suppression.
self.paste_burst.clear_after_explicit_paste();
// Track explicit paste so submit remains gated across multi-read bursts.
self.paste_burst
.mark_explicit_paste(char_count, Instant::now());
// Keep popup sync consistent with key handling: prefer slash popup; only
// sync file popup when slash popup is NOT active.
self.sync_command_popup();
Expand Down Expand Up @@ -3478,4 +3479,45 @@ mod tests {
assert_eq!(composer.textarea.text(), "z".repeat(count));
assert!(composer.pending_pastes.is_empty());
}

#[test]
fn fast_enter_after_single_char_is_treated_as_paste_newline() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

// Simulate a quick burst: first char immediately followed by Enter.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

// Enter should be treated as part of the burst (newline), not submit.
assert_eq!(InputResult::None, result);
// UI should not show buffered content until the burst flushes.
assert_eq!(composer.textarea.text(), "");

// After the flush interval, the buffered content should appear as a paste.
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let flushed = composer.flush_paste_burst_if_due();
assert!(flushed, "expected buffered paste to flush after delay");
let text = composer.textarea.text().to_string();
assert!(
!text.is_empty(),
"expected pasted content to be inserted after flush"
);
assert!(
text.contains('\n'),
"expected newline to be inserted instead of submit; got: {text:?}"
);
}
}
94 changes: 74 additions & 20 deletions codex-rs/tui/src/bottom_pane/paste_burst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,29 @@ use std::time::Instant;
// Detect quickly to avoid showing typed prefix before paste is recognized
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);

#[inline]
fn paste_guard_duration() -> Duration {
match std::env::var("CODEX_PASTE_GUARD_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
{
Some(ms) if ms > 0 => Duration::from_millis(ms),
_ => Duration::from_millis(180),
}
}

#[inline]
fn adaptive_window_for_size(size: usize) -> Duration {
let base = paste_guard_duration();
if size >= 2048 {
base + Duration::from_millis(320)
} else if size >= 1024 {
base + Duration::from_millis(160)
} else {
base
}
}

#[derive(Default)]
pub(crate) struct PasteBurst {
Expand All @@ -16,6 +38,8 @@ pub(crate) struct PasteBurst {
active: bool,
// Hold first fast char briefly to avoid rendering flicker
pending_first_char: Option<(char, Instant)>,
// Timestamp of most recent explicit/captured paste activity
last_paste_at: Option<Instant>,
}

pub(crate) enum CharDecision {
Expand Down Expand Up @@ -64,7 +88,8 @@ impl PasteBurst {
self.last_plain_char_time = Some(now);

if self.active {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
return CharDecision::BufferAppend;
}

Expand All @@ -77,7 +102,8 @@ impl PasteBurst {
// take() to clear pending; we already captured the held char above
let _ = self.pending_first_char.take();
self.buffer.push(held);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
return CharDecision::BeginBufferFromPending;
}

Expand Down Expand Up @@ -125,27 +151,53 @@ impl PasteBurst {
/// While bursting: accumulate a newline into the buffer instead of
/// submitting the textarea.
///
/// Returns true if a newline was appended (we are in a burst context),
/// false otherwise.
/// Returns true if a newline was appended (we are in or just entered a
/// burst context), false otherwise.
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
if self.is_active() {
// Normal case: already buffering a burst; just append a newline.
if self.is_active_internal() {
self.buffer.push('\n');
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
self.last_paste_at = Some(now);
true
} else {
false
// Fallback: if we very recently saw the first fast char and are
// holding it pending, treat an immediate Enter as part of the
// paste-like burst. This prevents the first newline from acting as
// submit on platforms where bracketed paste is unavailable.
if let Some((held, held_at)) = self.pending_first_char
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
{
self.active = true;
let _ = self.pending_first_char.take();
self.buffer.push(held);
self.buffer.push('\n');
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
self.last_paste_at = Some(now);
true
} else {
false
}
}
}

/// Decide if Enter should insert a newline (burst context) vs submit.
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
self.is_active() || in_burst_window
let in_burst_window = self
.burst_window_until
.is_some_and(|until| now <= until + Duration::from_millis(16));
let recent_explicit = self
.last_paste_at
.is_some_and(|t| now.saturating_duration_since(t) <= paste_guard_duration());
self.is_active() || in_burst_window || recent_explicit
}

/// Keep the burst window alive.
pub fn extend_window(&mut self, now: Instant) {
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
}

/// Begin buffering with retroactively grabbed text.
Expand All @@ -154,13 +206,17 @@ impl PasteBurst {
self.buffer.push_str(&grabbed);
}
self.active = true;
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len().saturating_add(grabbed.len()));
self.burst_window_until = Some(now + win);
self.last_paste_at = Some(now);
}

/// Append a char into the burst buffer.
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
self.buffer.push(ch);
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
let win = adaptive_window_for_size(self.buffer.len());
self.burst_window_until = Some(now + win);
self.last_paste_at = Some(now);
}

/// Try to append a char into the burst buffer only if a burst is already active.
Expand Down Expand Up @@ -244,13 +300,11 @@ impl PasteBurst {
self.active || !self.buffer.is_empty()
}

pub fn clear_after_explicit_paste(&mut self) {
self.last_plain_char_time = None;
self.consecutive_plain_char_burst = 0;
self.burst_window_until = None;
self.active = false;
self.buffer.clear();
self.pending_first_char = None;
pub fn mark_explicit_paste(&mut self, size: usize, now: Instant) {
if size >= 64 {
self.last_paste_at = Some(now);
self.burst_window_until = Some(now + adaptive_window_for_size(size));
}
}
}

Expand Down
Loading
Loading