From 1ef120689cacbc35f031c4e858f6e1119d7d42f3 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 2 Mar 2026 20:15:54 -0800 Subject: [PATCH] tui: use lightweight pending steer compare keys --- codex-rs/tui/src/chatwidget.rs | 8 ++- codex-rs/tui/src/chatwidget/realtime.rs | 86 ++++++++++++------------- codex-rs/tui/src/chatwidget/tests.rs | 21 +++--- 3 files changed, 54 insertions(+), 61 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f78764ab31..06e8614982 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -38,6 +38,7 @@ use std::time::Duration; use std::time::Instant; use crate::app_event::RealtimeAudioDeviceKind; +use self::realtime::PendingSteerCompareKey; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; @@ -760,7 +761,7 @@ impl From<&str> for UserMessage { struct PendingSteer { user_message: UserMessage, - compare_key: RenderedUserMessageEvent, + compare_key: PendingSteerCompareKey, } pub(crate) fn create_initial_user_message( @@ -4288,7 +4289,7 @@ impl ChatWidget { text_elements: text_elements.clone(), mention_bindings: mention_bindings.clone(), }, - compare_key: Self::rendered_user_message_event_from_normalized_items(&items), + compare_key: Self::pending_steer_compare_key_from_items(&items), }); let personality = self .config @@ -4656,10 +4657,11 @@ impl ChatWidget { unreachable!("user message item should convert to a legacy user message"); }; let rendered = Self::rendered_user_message_event_from_event(&event); + let compare_key = Self::pending_steer_compare_key_from_item(item); let should_render = if self .pending_steers .front() - .is_some_and(|pending| pending.compare_key == rendered) + .is_some_and(|pending| pending.compare_key == compare_key) { self.pending_steers.pop_front(); self.refresh_pending_input_preview(); diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index e1606beb0d..e5f8591082 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -1,10 +1,4 @@ use super::*; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; -use codex_protocol::models::is_image_close_tag_text; -use codex_protocol::models::is_image_open_tag_text; -use codex_protocol::models::is_local_image_close_tag_text; -use codex_protocol::models::is_local_image_open_tag_text; use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationClosedEvent; @@ -61,6 +55,12 @@ pub(super) struct RenderedUserMessageEvent { pub(super) text_elements: Vec, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct PendingSteerCompareKey { + pub(super) message: String, + pub(super) image_count: usize, +} + impl ChatWidget { pub(super) fn rendered_user_message_event_from_parts( message: String, @@ -87,52 +87,48 @@ impl ChatWidget { ) } - /// Build the compare key for a submitted pending steer using the same lossy - /// `UserInput -> ResponseInputItem -> UserMessageItem` normalization that core - /// applies when steering input into an active turn. - /// - /// Pending steers keep the full `UserMessage` for interrupt restore, but they - /// must match `ItemCompleted(UserMessage)` against the normalized shape core - /// later emits after draining pending input. - pub(super) fn rendered_user_message_event_from_normalized_items( + /// Build the compare key for a submitted pending steer without invoking the + /// expensive request-serialization path. Pending steers only need to match the + /// committed `ItemCompleted(UserMessage)` emitted after core drains input, which + /// preserves flattened text and total image count but not UI-only text ranges or + /// local image paths. + pub(super) fn pending_steer_compare_key_from_items( items: &[UserInput], - ) -> RenderedUserMessageEvent { - let response_input_item = ResponseInputItem::from(items.to_vec()); - let ResponseInputItem::Message { role, content } = response_input_item else { - unreachable!( - "user inputs must convert to ResponseInputItem::Message, got {response_input_item:?}" - ); - }; - debug_assert_eq!(role, "user"); - + ) -> PendingSteerCompareKey { let mut message = String::new(); - let mut remote_image_urls = Vec::new(); + let mut image_count = 0; - for (idx, content_item) in content.iter().enumerate() { - match content_item { - ContentItem::InputText { text } => { - if (is_local_image_open_tag_text(text) || is_image_open_tag_text(text)) - && matches!(content.get(idx + 1), Some(ContentItem::InputImage { .. })) - || (idx > 0 - && (is_local_image_close_tag_text(text) - || is_image_close_tag_text(text)) - && matches!(content.get(idx - 1), Some(ContentItem::InputImage { .. }))) - { - continue; - } - message.push_str(text); - } - ContentItem::InputImage { image_url } => remote_image_urls.push(image_url.clone()), - ContentItem::OutputText { .. } => {} + for item in items { + match item { + UserInput::Text { text, .. } => message.push_str(text), + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} } } - Self::rendered_user_message_event_from_parts( + PendingSteerCompareKey { message, - Vec::new(), - Vec::new(), - remote_image_urls, - ) + image_count, + } + } + + pub(super) fn pending_steer_compare_key_from_item( + item: &codex_protocol::items::UserMessageItem, + ) -> PendingSteerCompareKey { + let mut image_count = 0; + for input in &item.content { + match input { + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Text { .. } | UserInput::Skill { .. } | UserInput::Mention { .. } => {} + _ => {} + } + } + + PendingSteerCompareKey { + message: item.message(), + image_count, + } } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8e8bc0e380..44656013b1 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3236,12 +3236,10 @@ fn complete_assistant_message( fn pending_steer(text: &str) -> PendingSteer { PendingSteer { user_message: UserMessage::from(text), - compare_key: ChatWidget::rendered_user_message_event_from_parts( - text.to_string(), - Vec::new(), - Vec::new(), - Vec::new(), - ), + compare_key: PendingSteerCompareKey { + message: text.to_string(), + image_count: 0, + }, } } @@ -3875,7 +3873,7 @@ async fn item_completed_only_pops_front_pending_steer() { } #[tokio::test(flavor = "multi_thread")] -async fn normalized_item_completed_pops_pending_steer_with_local_image_and_text_elements() { +async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); @@ -3911,21 +3909,18 @@ async fn normalized_item_completed_pops_pending_steer_with_local_image_and_text_ let pending = chat.pending_steers.front().unwrap(); assert_eq!(pending.user_message.local_images.len(), 1); assert_eq!(pending.user_message.text_elements.len(), 1); - assert!(pending.compare_key.local_images.is_empty()); - assert!(pending.compare_key.text_elements.is_empty()); assert_eq!(pending.compare_key.message, text); - assert_eq!(pending.compare_key.remote_image_urls.len(), 1); - let compare_key = pending.compare_key.clone(); + assert_eq!(pending.compare_key.image_count, 1); complete_user_message_for_inputs( &mut chat, "user-1", vec![ UserInput::Image { - image_url: compare_key.remote_image_urls[0].clone(), + image_url: "data:image/png;base64,placeholder".to_string(), }, UserInput::Text { - text: compare_key.message, + text, text_elements: Vec::new(), }, ],