mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
35 KiB
35 KiB
PR #1549: Add paste summarization to Codex TUI
- URL: https://github.com/openai/codex/pull/1549
- Author: aibrahim-oai
- Created: 2025-07-12 05:00:17 UTC
- Updated: 2025-07-12 22:32:07 UTC
- Changes: +542/-16, Files changed: 12, Commits: 9
Description
Summary
- introduce
Pasteevent 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, andChatComposer
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
Full Diff
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 2909c2c594..3de3e78198 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -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"
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 151222a1d3..74aedfa353 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -61,4 +61,5 @@ unicode-segmentation = "1.12.0"
uuid = "1"
[dev-dependencies]
+insta = "1.43.1"
pretty_assertions = "1"
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 4b8b9b7812..e1dde8332d 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -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),
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
index dd89b85331..fd6b2479ee 100644
--- a/codex-rs/tui/src/app_event.rs
+++ b/codex-rs/tui/src/app_event.rs
@@ -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),
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 29bf74c810..e89187d165 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -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
+ ]
+ );
+ }
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 96f5c70285..350492b3e9 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -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) {
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
new file mode 100644
index 0000000000..fa604c862b
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap
@@ -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╯"
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
new file mode 100644
index 0000000000..a89076d8aa
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap
@@ -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╯"
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
new file mode 100644
index 0000000000..39a62da400
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
@@ -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╯"
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
new file mode 100644
index 0000000000..cd94095431
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap
@@ -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╯"
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
new file mode 100644
index 0000000000..e6b55e36d8
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap
@@ -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╯"
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 0b623132b5..865e339763 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -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();
Review Comments
codex-rs/tui/src/bottom_pane/chat_composer.rs
- Created: 2025-07-12 18:12:24 UTC | Link: https://github.com/openai/codex/pull/1549#discussion_r2202857188
@@ -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 = 100;
100 feels a bit low to me. Maybe we start with 500 or even
1_000and see how that goes?If the user is likely to edit the text, then it will be frustrating that it's replaced with the placeholder, which is why I'm worried about making the threshold too low.
I don't really want to make a config option for this, though we could...
codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap
- Created: 2025-07-12 18:18:13 UTC | Link: https://github.com/openai/codex/pull/1549#discussion_r2202858302
@@ -0,0 +1,8 @@
+---
+source: tui/src/bottom_pane/chat_composer.rs
+expression: terminal.backend()
+---
+"╭────────────────────────────╮"
+"│t[Pasted Content 105 chars] │"
what's the
tin front there?