tui: use lightweight pending steer compare keys

This commit is contained in:
Charles Cunningham
2026-03-02 20:15:54 -08:00
parent 2fc7bb32d8
commit 1ef120689c
3 changed files with 54 additions and 61 deletions

View File

@@ -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();

View File

@@ -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<TextElement>,
}
#[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)]

View File

@@ -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(),
},
],