diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 7d51b3d686..555f4186bf 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -441,7 +441,32 @@ impl App { return Ok(false); } } - if matches!(event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release) { + // Raw pastes can arrive as thousands of Char/Enter/Tab events. Honor the composer's + // paste batching instead of rendering the entire screen before each buffered key. + // Keys owned by the transcript and non-text keys still need fresh geometry immediately. + if let TuiEvent::Key(key) = event + && key.kind != KeyEventKind::Release + && (!self.chat_widget.is_in_paste_burst() + || self.transcript_view.is_search_editing() + || self.transcript_view.is_activity_focused() + || self.transcript_view.owns_interaction_key(*key) + || self.backtrack.overlay_preview_active + || self.keymap.app.focus_activity.is_pressed(*key) + || self.keymap.app.find_transcript.is_pressed(*key) + || match key.code { + KeyCode::Char(_) => { + crate::key_hint::has_ctrl_or_alt(key.modifiers) + || key.modifiers.intersects( + KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META, + ) + } + KeyCode::Enter => { + !matches!(key.modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) + } + KeyCode::Tab => key.modifiers != KeyModifiers::NONE, + _ => true, + }) + { let size = tui.prepare_draw_size()?; self.render_owned_transcript(tui, size)?; } diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index 4c4119b344..15cf0bf8c9 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -723,6 +723,143 @@ async fn escape_returns_to_latest_without_changing_the_composer_draft() -> Resul Ok(()) } +#[tokio::test] +async fn raw_multiline_paste_defers_rendering_and_submits_once() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.local_settings.tui.animations = false; + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec!["Before paste".into()]))]; + tokio::time::pause(); + // The first character starts a possible burst; defer further renders until the draw. + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Char('D').into()), + ) + .await?; + let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec!["After paste".into()]))]; + let rest = "ebug\t한국어\tASCII\t0123456789\n".repeat(80) + "end"; + for ch in rest.chars() { + let code = match ch { + '\n' => KeyCode::Enter, + '\t' => KeyCode::Tab, + ch => KeyCode::Char(ch), + }; + // Windows reports AltGr text as Ctrl+Alt, including with Shift held. + #[cfg(windows)] + let modifiers = match ch { + '한' => KeyModifiers::CONTROL | KeyModifiers::ALT, + '국' => KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT, + _ => KeyModifiers::NONE, + }; + #[cfg(not(windows))] + let modifiers = KeyModifiers::NONE; + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyEvent::new(code, modifiers)), + ) + .await?; + } + assert_eq!( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + &before + ); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + let during_paste = screen(&tui); + // Wait out Windows' active-paste timeout and accidental-Enter suppression window. + tokio::time::advance(Duration::from_secs(/*secs*/ 1)).await; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + assert!(screen(&tui).contains("After paste")); + insta::assert_snapshot!( + "raw_paste_burst_render", + format!( + "During paste:\n{during_paste}\n\nAfter flush:\n{}", + screen(&tui) + ) + ); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into())) + .await?; + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec![format!("D{rest}")] + ); + tokio::time::resume(); + server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + +#[tokio::test] +async fn accepted_find_defers_paste_rendering_but_keeps_enter() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.local_settings.tui.animations = false; + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![ + "Before paste needle".into(), + ]))]; + for event in [ + TuiEvent::Key(KeyCode::F(3).into()), + TuiEvent::Paste("needle".to_string()), + TuiEvent::Draw, + TuiEvent::Key(KeyCode::Enter.into()), + ] { + app.handle_tui_event(&mut tui, &mut server, event).await?; + } + assert!(!app.transcript_view.is_search_editing()); + assert!(app.transcript_view.has_active_interaction()); + tokio::time::pause(); + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Char('D').into()), + ) + .await?; + let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone(); + app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![ + "After paste needle".into(), + ]))]; + for ch in "ebug log ".repeat(80).chars() { + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Char(ch).into()), + ) + .await?; + } + assert_eq!( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + &before + ); + // Enter still belongs to accepted Find, even in the middle of a paste burst. + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into())) + .await?; + assert_ne!( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + &before + ); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + tokio::time::advance(Duration::from_secs(/*secs*/ 1)).await; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + format!("D{}", "ebug log ".repeat(80)) + ); + tokio::time::resume(); + server.shutdown().await?; + tui.set_owned_screen(/*owned*/ false)?; + Ok(()) +} + #[tokio::test] async fn composer_paste_releases_selection_and_retains_normal_cursor_movement() -> Result<()> { let mut app = crate::app::test_support::make_test_app().await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap new file mode 100644 index 0000000000..324f2da7e4 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap @@ -0,0 +1,54 @@ +--- +source: tui/src/app/owned_transcript_input_tests.rs +expression: "format!(\"During paste:\\n{during_paste}\\n\\nAfter flush:\\n{}\", screen(&tui))" +--- +During paste: +Before paste + + + + + + + + + + + + + + + + + + + +› Ask Codex to do anything + + GPT-5.6-Sol default · /tmp/project + ? for shortcuts + +After flush: +After paste + + + + + + + + + + + + + + + + + + + +› [Pasted Content 2084 chars] + + GPT-5.6-Sol default · /tmp/project diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 7c25abd4e2..2d125f88b5 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -499,6 +499,10 @@ impl ChatWidget { self.refresh_startup_recovery(); } + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.bottom_pane.is_in_paste_burst() + } + // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { @@ -506,7 +510,7 @@ impl ChatWidget { // A paste just flushed; request an immediate redraw and skip this frame. self.request_redraw(); true - } else if self.bottom_pane.is_in_paste_burst() { + } else if self.is_in_paste_burst() { // While capturing a burst, schedule a follow-up tick and skip this frame // to avoid redundant renders between ticks. frame_requester.schedule_frame_in(