mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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;
|
||||
|
||||
+54
@@ -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
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user