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
76 changes: 67 additions & 9 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
//! and attachment pruning, and clears pending paste state on success.
//!
//! # Large Paste Placeholders
//!
//! Large pastes insert an element placeholder in the buffer and store the full text in
//! `pending_pastes`. The placeholder label is derived from the pasted character count:
//!
//! - First paste of a given size uses `[Pasted Content N chars]`.
//! - Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the
//! next suffix is computed from the placeholders that still exist in `pending_pastes`.
//! - When all placeholders for a size are deleted (which removes their pending entries), the next
//! paste of that size reuses the base label without a suffix.
//!
//! # Non-bracketed Paste Bursts
//!
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
Expand Down Expand Up @@ -256,7 +267,6 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
/// Invariant: attached images are labeled `[Image #1]..[Image #N]` in vec order.
attached_images: Vec<AttachedImage>,
Expand Down Expand Up @@ -347,7 +357,6 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
large_paste_counters: HashMap::new(),
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
Expand Down Expand Up @@ -891,14 +900,27 @@ impl ChatComposer {
.is_some_and(|expires_at| Instant::now() < expires_at)
}

fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
fn next_large_paste_placeholder(&self, char_count: usize) -> String {
let base = format!("[Pasted Content {char_count} chars]");
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
*next_suffix += 1;
if *next_suffix == 1 {
let prefix = format!("{base} #");
let mut max_suffix = 0usize;

for (placeholder, _) in &self.pending_pastes {
if placeholder == &base {
max_suffix = max_suffix.max(1);
continue;
}
if let Some(suffix) = placeholder.strip_prefix(&prefix)
&& let Ok(value) = suffix.parse::<usize>()
{
max_suffix = max_suffix.max(value);
}
}

if max_suffix == 0 {
base
} else {
format!("{base} #{next_suffix}")
format!("{base} #{}", max_suffix + 1)
}
}

Expand Down Expand Up @@ -4662,8 +4684,8 @@ mod tests {
assert_eq!(composer.pending_pastes[0].1, paste);
}

/// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new
/// paste of the same length gets a new unique placeholder label.
/// Behavior: large-paste placeholder numbering continues when another placeholder of the
/// same length still exists, so a new paste gets a new unique placeholder label.
#[test]
fn large_paste_numbering_does_not_reuse_after_deletion() {
use crossterm::event::KeyCode;
Expand Down Expand Up @@ -4704,6 +4726,42 @@ mod tests {
assert_eq!(composer.pending_pastes[1].0, third);
}

/// Behavior: if all placeholders of a given length are removed, numbering resets to the
/// base placeholder on the next paste.
#[test]
fn large_paste_numbering_reuses_after_all_deleted() {
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 paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let base = format!("[Pasted Content {} chars]", paste.chars().count());

composer.handle_paste(paste.clone());
assert_eq!(composer.textarea.text(), base);
assert_eq!(composer.pending_pastes.len(), 1);

composer.textarea.set_cursor(composer.textarea.text().len());
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.textarea.text().is_empty());
assert!(composer.pending_pastes.is_empty());

composer.handle_paste(paste);
assert_eq!(composer.textarea.text(), base);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, base);
}

#[test]
fn test_partial_placeholder_deletion() {
use crossterm::event::KeyCode;
Expand Down
11 changes: 11 additions & 0 deletions docs/tui-chat-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ positional args, Enter auto-submits without calling `prepare_submission_text`. T
- Prunes attachments based on expanded placeholders.
- Clears pending pastes after a successful auto-submit.

### Large paste placeholders

Large pastes (over `LARGE_PASTE_CHAR_THRESHOLD`) insert an element placeholder and store the full
text in `pending_pastes`. Placeholder labels are derived from the pasted character count:

- First paste of a given size uses `[Pasted Content N chars]`.
- Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the
next suffix is computed from placeholders that still exist in `pending_pastes`.
- When all placeholders for a size are deleted (which removes their pending entries), the next
paste of that size reuses the base label without a suffix.

## Paste burst: concepts and assumptions

The burst detector is intentionally conservative: it only processes “plain” character input
Expand Down
Loading