Compare commits

...

2 Commits

Author SHA1 Message Date
Charles Cunningham
5ac1407186 docs 2026-01-28 11:53:23 -08:00
Charles Cunningham
3f13d3de0c Fix large paste placeholder numbering after deletion 2026-01-28 11:53:23 -08:00
2 changed files with 78 additions and 9 deletions

View File

@@ -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
@@ -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>,
@@ -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,
@@ -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)
}
}
@@ -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;
@@ -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;

View File

@@ -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