From 38b064c31b1f7464b281006316ec878ed23fea77 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 15 Jul 2026 16:18:38 +0000 Subject: [PATCH] Render TUI prompts before submitting user turns (#33373) ## Why Starting a user turn can wait on remote work. Show the submitted prompt immediately instead of leaving the input invisible during that wait. ## What changed - Queue optimistic prompt history before app-event submissions and render a frame before starting the turn. - Preserve the existing submit-failure behavior for direct submissions. ## Testing - Verify that an initial prompt is added to history before its user-turn event is submitted during session replay. GitOrigin-RevId: 57f30c6e0067127e6b082a206f7d154fcfef8d7d --- codex-rs/tui/src/app/event_dispatch.rs | 9 +++ codex-rs/tui/src/app/tests.rs | 6 ++ .../tui/src/chatwidget/input_submission.rs | 60 ++++++++++--------- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e533ca34ab..f1dc58fd51 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -411,6 +411,15 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { + if matches!(&op, AppCommand::UserTurn { .. }) { + self.handle_draw_pre_render(tui)?; + if self.transcript_reflow.has_pending_reflow() { + self.transcript_reflow.schedule_immediate(); + self.maybe_run_resize_reflow(tui)?; + } + self.chat_widget.pre_draw_tick(); + self.render_chat_widget_frame(tui)?; + } self.chat_widget.prepare_local_op_submission(&op); self.submit_active_thread_op(app_server, op).await?; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 20886160e9..b3cda29aea 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -367,12 +367,14 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm .await?; let mut saw_replayed_answer = false; + let mut saw_optimistic_prompt = false; let mut submitted_items = None; while let Ok(event) = app_event_rx.try_recv() { match event { AppEvent::InsertHistoryCell(cell) => { let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); saw_replayed_answer |= transcript.contains("earlier prompt"); + saw_optimistic_prompt |= transcript.contains(&initial_prompt); } AppEvent::SubmitThreadOp { thread_id: op_thread_id, @@ -382,6 +384,10 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm submitted_items = Some(items); } AppEvent::CodexOp(Op::UserTurn { items, .. }) => { + assert!( + saw_optimistic_prompt, + "expected optimistic prompt before turn submission" + ); submitted_items = Some(items); } _ => {} diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index f1b84f4d1b..ec55f34f81 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -350,6 +350,25 @@ impl ChatWidget { collaboration_mode, personality, ); + let submitted_message = UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }; + + // App-event submissions are handled serially, and turn/start can wait on remote work. + // Queue the optimistic prompt first so the user's input is visible while that happens. + // Direct submissions do not share that queue, so keep their existing failure behavior. + let render_before_submit = + render_in_history && matches!(&self.codex_op_target, CodexOpTarget::AppEvent); + if render_before_submit { + self.on_user_message_display(user_message_display_for_history( + submitted_message.clone(), + &history_record, + )); + } if !self.submit_op(op.clone()) { return (false, None); @@ -360,7 +379,8 @@ impl ChatWidget { // Persist the submitted text to cross-session message history. Mentions are encoded into // placeholder syntax so recall can reconstruct the mention bindings in a future session. - let encoded_mentions = mention_bindings + let encoded_mentions = submitted_message + .mention_bindings .iter() .map(|binding| LinkedMention { sigil: binding.sigil, @@ -369,8 +389,11 @@ impl ChatWidget { }) .collect::>(); let history_text = match &history_record { - UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { - Some(encode_history_mentions(&text, &encoded_mentions)) + UserMessageHistoryRecord::UserMessageText if !submitted_message.text.is_empty() => { + Some(encode_history_mentions( + &submitted_message.text, + &encoded_mentions, + )) } UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { Some(encode_history_mentions(&history.text, &encoded_mentions)) @@ -390,30 +413,13 @@ impl ChatWidget { } if render_in_history { - self.safety_buffering_prompt = Some(UserMessage { - text: text.clone(), - local_images: local_images.clone(), - remote_image_urls: remote_image_urls.clone(), - text_elements: text_elements.clone(), - mention_bindings: mention_bindings.clone(), - }); - } - - // Show replayable user content in conversation history. - let display_user_message = render_in_history.then(|| { - user_message_display_for_history( - UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - &history_record, - ) - }); - if let Some(display) = display_user_message { - self.on_user_message_display(display); + self.safety_buffering_prompt = Some(submitted_message.clone()); + if !render_before_submit { + self.on_user_message_display(user_message_display_for_history( + submitted_message, + &history_record, + )); + } } self.transcript.needs_final_message_separator = false;