diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2909c2c5..3de3e781 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 151222a1..74aedfa3 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 4b8b9b78..e1dde833 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 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 dd89b853..fd6b2479 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 29bf74c8..e89187d1 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, current_file_query: Option, + 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) { // 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 96f5c702..350492b3 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 00000000..fa604c86 --- /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 00000000..a89076d8 --- /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 00000000..39a62da4 --- /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 00000000..cd940954 --- /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 00000000..e6b55e36 --- /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 0b623132..865e3397 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 = Vec::new();