From ecebd679c389a74cc60334dcf1e11fe28779a3eb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 10 Oct 2026 19:06:48 +0000 Subject: [PATCH] Defer owned transcript redraws during raw paste bursts (#52964) ## Why Raw pastes can arrive as thousands of character, Enter, and Tab events. Redrawing the entire owned transcript before each buffered key defeats the composer's paste batching. ## What changed Skip those redraws while the composer is capturing a paste burst. Continue refreshing geometry immediately for transcript interactions, shortcuts, and non-text keys, including Enter when an accepted Find owns it. ## Testing Add regression tests and a render snapshot covering multiline raw paste with tabs and Korean text, deferred rendering until the burst flushes, and a single submission of the complete pasted text. Also verify that an accepted Find still handles Enter during a paste burst without submitting the draft. GitOrigin-RevId: 49d89958a89c165249322463e6075377c517bd8e --- codex-rs/tui/src/app/owned_transcript.rs | 27 +++- .../src/app/owned_transcript_input_tests.rs | 137 ++++++++++++++++++ ...__input_tests__raw_paste_burst_render.snap | 54 +++++++ codex-rs/tui/src/chatwidget/interaction.rs | 6 +- 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__input_tests__raw_paste_burst_render.snap 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(