mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
820 lines
35 KiB
Markdown
820 lines
35 KiB
Markdown
# 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 `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
|
||
|
||
## Full Diff
|
||
|
||
```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
|
||
|
||
```diff
|
||
@@ -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_000` and 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
|
||
|
||
```diff
|
||
@@ -0,0 +1,8 @@
|
||
+---
|
||
+source: tui/src/bottom_pane/chat_composer.rs
|
||
+expression: terminal.backend()
|
||
+---
|
||
+"╭────────────────────────────╮"
|
||
+"│t[Pasted Content 105 chars] │"
|
||
```
|
||
|
||
> what's the `t` in front there? |