Add paste summarization to Codex TUI (#1549)

## Summary
- introduce `Paste` event to avoid per-character paste handling
- collapse large pasted blocks to `[Pasted Content X lines]`
- store the real text so submission still includes it
- wire paste handling through `App`, `ChatWidget`, `BottomPane`, and
`ChatComposer`

## Testing
- `cargo test -p codex-tui`


------
https://chatgpt.com/codex/tasks/task_i_6871e24abf80832184d1f3ca0c61a5ee


https://github.com/user-attachments/assets/eda7412f-da30-4474-9f7c-96b49d48fbf8
This commit is contained in:
aibrahim-oai
2025-07-12 15:32:00 -07:00
committed by GitHub
parent fa6d507c51
commit 72504f1d9c
12 changed files with 542 additions and 16 deletions

30
codex-rs/Cargo.lock generated
View File

@@ -787,6 +787,7 @@ dependencies = [
"color-eyre",
"crossterm",
"image",
"insta",
"lazy_static",
"mcp-types",
"path-clean",
@@ -871,6 +872,18 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -1230,6 +1243,12 @@ dependencies = [
"log",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -2110,6 +2129,17 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]]
name = "insta"
version = "1.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
dependencies = [
"console",
"once_cell",
"similar",
]
[[package]]
name = "instability"
version = "0.3.7"

View File

@@ -61,4 +61,5 @@ unicode-segmentation = "1.12.0"
uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"

View File

@@ -98,21 +98,7 @@ impl<'a> App<'a> {
scroll_event_helper.scroll_down();
}
crossterm::event::Event::Paste(pasted) => {
use crossterm::event::KeyModifiers;
for ch in pasted.chars() {
let key_event = match ch {
'\n' | '\r' => {
// Represent newline as <Shift+Enter> so that the bottom
// pane treats it as a literal newline instead of a submit
// action (submission is only triggered on Enter *without*
// any modifiers).
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)
}
_ => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()),
};
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
@@ -223,6 +209,9 @@ impl<'a> App<'a> {
AppEvent::Scroll(scroll_delta) => {
self.dispatch_scroll_event(scroll_delta);
}
AppEvent::Paste(text) => {
self.dispatch_paste_event(text);
}
AppEvent::CodexEvent(event) => {
self.dispatch_codex_event(event);
}
@@ -343,6 +332,13 @@ impl<'a> App<'a> {
}
}
fn dispatch_paste_event(&mut self, pasted: String) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::Login { .. } | AppState::GitWarning { .. } => {}
}
}
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),

View File

@@ -12,6 +12,9 @@ pub(crate) enum AppEvent {
KeyEvent(KeyEvent),
/// Text pasted from the terminal clipboard.
Paste(String),
/// Scroll event with a value representing the "scroll delta" as the net
/// scroll up/down events within a short time window.
Scroll(i32),

View File

@@ -28,6 +28,9 @@ const MIN_TEXTAREA_ROWS: usize = 1;
const BORDER_LINES: u16 = 2;
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
/// If the pasted content exceeds this number of characters, replace it with a
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
/// Result returned when the user interacts with the text area.
pub enum InputResult {
@@ -43,6 +46,7 @@ pub(crate) struct ChatComposer<'a> {
ctrl_c_quit_hint: bool,
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
}
/// Popup state at most one can be visible at any time.
@@ -66,6 +70,7 @@ impl ChatComposer<'_> {
ctrl_c_quit_hint: false,
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
};
this.update_border(has_input_focus);
this
@@ -126,6 +131,20 @@ impl ChatComposer<'_> {
self.update_border(has_focus);
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
let placeholder = format!("[Pasted Content {char_count} chars]");
self.textarea.insert_str(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else {
self.textarea.insert_str(&pasted);
}
self.sync_command_popup();
self.sync_file_search_popup();
true
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`.
@@ -414,10 +433,18 @@ impl ChatComposer<'_> {
alt: false,
ctrl: false,
} => {
let text = self.textarea.lines().join("\n");
let mut text = self.textarea.lines().join("\n");
self.textarea.select_all();
self.textarea.cut();
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
}
}
self.pending_pastes.clear();
if text.is_empty() {
(InputResult::None, true)
} else {
@@ -443,10 +470,71 @@ impl ChatComposer<'_> {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let Input {
key: Key::Backspace,
..
} = input
{
if self.try_remove_placeholder_at_cursor() {
return (InputResult::None, true);
}
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.lines().join("\n");
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
(InputResult::None, true)
}
/// Attempts to remove a placeholder if the cursor is at the end of one.
/// Returns true if a placeholder was removed.
fn try_remove_placeholder_at_cursor(&mut self) -> bool {
let (row, col) = self.textarea.cursor();
let line = self
.textarea
.lines()
.get(row)
.map(|s| s.as_str())
.unwrap_or("");
// Find any placeholder that ends at the cursor position
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
if col < ph.len() {
return None;
}
let potential_ph_start = col - ph.len();
if line[potential_ph_start..col] == *ph {
Some(ph.clone())
} else {
None
}
});
if let Some(placeholder) = placeholder_to_remove {
// Remove the entire placeholder from the text
let placeholder_len = placeholder.len();
for _ in 0..placeholder_len {
self.textarea.input(Input {
key: Key::Backspace,
ctrl: false,
alt: false,
shift: false,
});
}
// Remove from pending pastes
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
true
} else {
false
}
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -624,7 +712,10 @@ impl WidgetRef for &ChatComposer<'_> {
#[cfg(test)]
mod tests {
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use tui_textarea::TextArea;
#[test]
@@ -770,4 +861,324 @@ mod tests {
);
}
}
#[test]
fn handle_paste_small_inserts_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let needs_redraw = composer.handle_paste("hello".to_string());
assert!(needs_redraw);
assert_eq!(composer.textarea.lines(), ["hello"]);
assert!(composer.pending_pastes.is_empty());
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, "hello"),
_ => panic!("expected Submitted"),
}
}
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
let needs_redraw = composer.handle_paste(large.clone());
assert!(needs_redraw);
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
assert_eq!(composer.textarea.lines(), [placeholder.as_str()]);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].0, placeholder);
assert_eq!(composer.pending_pastes[0].1, large);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted(text) => assert_eq!(text, large),
_ => panic!("expected Submitted"),
}
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn edit_clears_pending_paste() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
composer.handle_paste(large);
assert_eq!(composer.pending_pastes.len(), 1);
// Any edit that removes the placeholder should clear pending_paste
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(composer.pending_pastes.is_empty());
}
#[test]
fn ui_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
let test_cases = vec![
("empty", None),
("small", Some("short".to_string())),
("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
("multiple_pastes", None),
("backspace_after_pastes", None),
];
for (name, input) in test_cases {
// Create a fresh composer for each test case
let mut composer = ChatComposer::new(true, sender.clone());
if let Some(text) = input {
composer.handle_paste(text);
} else if name == "multiple_pastes" {
// First large paste
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
// Second large paste
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
// Small paste
composer.handle_paste(" another short paste".to_string());
} else if name == "backspace_after_pastes" {
// Three large pastes
composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
// Move cursor to end and press backspace
composer.textarea.move_cursor(tui_textarea::CursorMove::End);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
terminal
.draw(|f| f.render_widget_ref(&composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
// Define test cases: (paste content, is_large)
let test_cases = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
// Expected states after each paste
let mut expected_text = String::new();
let mut expected_pending_count = 0;
// Apply all pastes and build expected state
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
expected_text.push_str(&placeholder);
expected_pending_count += 1;
} else {
expected_text.push_str(content);
}
(expected_text.clone(), expected_pending_count)
})
.collect();
// Verify all intermediate states were correct
assert_eq!(
states,
vec![
(
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
1
),
(
format!(
"[Pasted Content {} chars] and ",
test_cases[0].0.chars().count()
),
1
),
(
format!(
"[Pasted Content {} chars] and [Pasted Content {} chars]",
test_cases[0].0.chars().count(),
test_cases[2].0.chars().count()
),
2
),
]
);
// Submit and verify final expansion
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
} else {
panic!("expected Submitted");
}
}
#[test]
fn test_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
// Define test cases: (content, is_large)
let test_cases = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
// Apply all pastes
let mut current_pos = 0;
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
composer.handle_paste(content.clone());
if *is_large {
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
current_pos += placeholder.len();
} else {
current_pos += content.len();
}
(
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
current_pos,
)
})
.collect();
// Delete placeholders one by one and collect states
let mut deletion_states = vec![];
// First deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
));
// Second deletion
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
composer.textarea.lines().join("\n").len() as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
deletion_states.push((
composer.textarea.lines().join("\n"),
composer.pending_pastes.len(),
));
// Verify all states
assert_eq!(
deletion_states,
vec![
(" and [Pasted Content 1006 chars]".to_string(), 1),
(" and ".to_string(), 0),
]
);
}
#[test]
fn test_partial_placeholder_deletion() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender);
// Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [
5, // Delete from middle - should clear tracking
0, // Delete from end - should clear tracking
];
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
let states: Vec<_> = test_cases
.into_iter()
.map(|pos_from_end| {
composer.handle_paste(paste.clone());
composer
.textarea
.move_cursor(tui_textarea::CursorMove::Jump(
0,
(placeholder.len() - pos_from_end) as u16,
));
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.textarea.lines().join("\n").contains(&placeholder),
composer.pending_pastes.len(),
);
composer.textarea.select_all();
composer.textarea.cut();
result
})
.collect();
assert_eq!(
states,
vec![
(false, 0), // After deleting from middle
(false, 0), // After deleting from end
]
);
}
}

View File

@@ -82,6 +82,15 @@ impl BottomPane<'_> {
}
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
self.request_redraw();
}
}
}
/// Update the status indicator text (only when the `StatusIndicatorView` is
/// active).
pub(crate) fn update_status_text(&mut self, text: String) {

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│[Pasted Content 1002 chars][Pasted Content 1004 chars] │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│ send a message │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│[Pasted Content 1005 chars] │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"

View File

@@ -0,0 +1,14 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
"│short │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"

View File

@@ -174,6 +174,12 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
if matches!(self.input_focus, InputFocus::BottomPane) {
self.bottom_pane.handle_paste(text);
}
}
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
let mut items: Vec<InputItem> = Vec::new();