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
This commit is contained in:
Eric Traut
2026-10-10 19:15:09 +00:00
committed by copyberry
parent 47bcb3a3f9
commit ecebd679c3
4 changed files with 222 additions and 2 deletions
+26 -1
View File
@@ -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)?;
}
@@ -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;
@@ -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
+5 -1
View File
@@ -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(