mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Stack 1. [1 of 3] Support long raw TUI goal objectives - #27508 2. **[2 of 3] Support long pasted text in TUI goals** - this PR 3. [3 of 3] Support images in TUI goals - #27510 ## Why Large text pasted into the TUI composer is represented as a paste placeholder plus pending paste metadata. For `/goal`, preserving only the visible placeholder is not enough: the agent would see a short placeholder string instead of the actual pasted text, and the long-text support from the first PR would never see the payload. The TUI also needs to avoid writing stale sidecar files when a user pastes a large block and then deletes its placeholder before submitting the goal. ## What Changed - Introduces a TUI `GoalDraft` for goal submissions so `/goal`, `/goal edit`, and queued goal commands can carry objective text plus text elements and pending paste payloads. - Materializes active pasted-text placeholders to `pasted-text-N.txt` files through the app-server filesystem path introduced in #27508. - Rewrites active paste placeholders in the persisted objective to file references, while leaving literal placeholder-looking text alone. - Filters out deleted paste placeholders so otherwise-small goals do not require `$CODEX_HOME` or remote filesystem writes. - Preserves pending paste metadata when a `/goal` command is queued before a thread exists. ## Verification - Added goal materialization tests for active paste placeholders, deleted paste placeholders, and whitespace-only paste payloads. - Added/updated TUI slash-command tests for large pasted text, queued `/goal` commands before thread start, and queued oversized goal behavior. ## Manual Testing - Used real terminal bracketed-paste sequences through a remote TUI session. A 1,228-byte multiline paste became `pasted-text-1.txt`; its first/last lines and byte count matched exactly, and the persisted objective referenced the server-host path. - Pasted a large block, deleted its placeholder, and submitted a small replacement objective. No new directory or sidecar file was created. - Added two same-length large pastes to one goal. The composer disambiguated their visible placeholders, and materialization preserved order and contents in `pasted-text-1.txt` and `pasted-text-2.txt`. - Submitted a whitespace-only large paste and verified the goal was rejected as empty without writing a file. - Submitted a pasted-text replacement while another goal was active, verified no file was written before confirmation, then canceled and confirmed the original goal remained unchanged. - Combined a large paste with enough raw text to exceed 4,000 characters after placeholder rewriting. The paste sidecar and `goal-objective.md` were created in the same remote attachment directory, and `/goal edit` restored the rewritten objective with its sidecar reference.
642 lines
21 KiB
Rust
642 lines
21 KiB
Rust
//! User-message models and helpers for the chat widget.
|
|
//!
|
|
//! The app-server preserves user input as structured chunks, while chat history
|
|
//! renders a single prompt row. This module owns the draft/message data models,
|
|
//! merge/remap behavior, display projection, and the small compare key used to
|
|
//! suppress duplicate rows for pending steers.
|
|
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::collections::VecDeque;
|
|
use std::ops::Deref;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::bottom_pane::LocalImageAttachment;
|
|
use crate::bottom_pane::MentionBinding;
|
|
use crate::bottom_pane::QueuedInputAction;
|
|
use codex_app_server_protocol::TextElement as AppServerTextElement;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::CollaborationModeMask;
|
|
use codex_protocol::models::local_image_label_text;
|
|
use codex_protocol::user_input::ByteRange;
|
|
use codex_protocol::user_input::TextElement;
|
|
|
|
use super::ChatWidget;
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(crate) struct UserMessage {
|
|
pub(crate) text: String,
|
|
pub(crate) local_images: Vec<LocalImageAttachment>,
|
|
/// Remote image attachments represented as URLs (for example data URLs)
|
|
/// provided by app-server clients.
|
|
///
|
|
/// Unlike `local_images`, these are not created by TUI image attach/paste
|
|
/// flows. The TUI can restore and remove them while editing/backtracking.
|
|
pub(crate) remote_image_urls: Vec<String>,
|
|
pub(crate) text_elements: Vec<TextElement>,
|
|
pub(crate) mention_bindings: Vec<MentionBinding>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) enum UserMessageHistoryRecord {
|
|
UserMessageText,
|
|
Override(UserMessageHistoryOverride),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) struct UserMessageHistoryOverride {
|
|
pub(super) text: String,
|
|
pub(super) text_elements: Vec<TextElement>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(super) enum ShellEscapePolicy {
|
|
Allow,
|
|
Disallow,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(super) struct QueuedUserMessage {
|
|
pub(super) user_message: UserMessage,
|
|
pub(super) action: QueuedInputAction,
|
|
pub(super) pending_pastes: Vec<(String, String)>,
|
|
}
|
|
|
|
impl QueuedUserMessage {
|
|
pub(super) fn new(user_message: UserMessage, action: QueuedInputAction) -> Self {
|
|
Self {
|
|
user_message,
|
|
action,
|
|
pending_pastes: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn into_user_message(self) -> UserMessage {
|
|
self.user_message
|
|
}
|
|
}
|
|
|
|
impl From<UserMessage> for QueuedUserMessage {
|
|
fn from(user_message: UserMessage) -> Self {
|
|
Self::new(user_message, QueuedInputAction::Plain)
|
|
}
|
|
}
|
|
|
|
impl Deref for QueuedUserMessage {
|
|
type Target = UserMessage;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.user_message
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(super) enum QueueDrain {
|
|
Continue,
|
|
Stop,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub(super) struct ThreadComposerState {
|
|
pub(super) text: String,
|
|
pub(super) local_images: Vec<LocalImageAttachment>,
|
|
pub(super) remote_image_urls: Vec<String>,
|
|
pub(super) text_elements: Vec<TextElement>,
|
|
pub(super) mention_bindings: Vec<MentionBinding>,
|
|
pub(super) pending_pastes: Vec<(String, String)>,
|
|
}
|
|
|
|
impl ThreadComposerState {
|
|
pub(super) fn has_content(&self) -> bool {
|
|
!self.text.is_empty()
|
|
|| !self.local_images.is_empty()
|
|
|| !self.remote_image_urls.is_empty()
|
|
|| !self.text_elements.is_empty()
|
|
|| !self.mention_bindings.is_empty()
|
|
|| !self.pending_pastes.is_empty()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(crate) struct ThreadInputState {
|
|
pub(super) composer: Option<ThreadComposerState>,
|
|
pub(super) pending_steers: VecDeque<UserMessage>,
|
|
pub(super) pending_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
|
pub(super) pending_steer_compare_keys: VecDeque<PendingSteerCompareKey>,
|
|
pub(super) rejected_steers_queue: VecDeque<UserMessage>,
|
|
pub(super) rejected_steer_history_records: VecDeque<UserMessageHistoryRecord>,
|
|
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
|
|
pub(super) queued_user_message_history_records: VecDeque<UserMessageHistoryRecord>,
|
|
pub(super) user_turn_pending_start: bool,
|
|
pub(super) current_collaboration_mode: CollaborationMode,
|
|
pub(super) active_collaboration_mask: Option<CollaborationModeMask>,
|
|
pub(super) task_running: bool,
|
|
pub(super) agent_turn_running: bool,
|
|
}
|
|
|
|
impl From<String> for UserMessage {
|
|
fn from(text: String) -> Self {
|
|
Self {
|
|
text,
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
// Plain text conversion has no UI element ranges.
|
|
text_elements: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&str> for UserMessage {
|
|
fn from(text: &str) -> Self {
|
|
Self {
|
|
text: text.to_string(),
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
// Plain text conversion has no UI element ranges.
|
|
text_elements: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) struct PendingSteer {
|
|
pub(super) user_message: UserMessage,
|
|
pub(super) history_record: UserMessageHistoryRecord,
|
|
pub(super) compare_key: PendingSteerCompareKey,
|
|
}
|
|
|
|
pub(crate) fn create_initial_user_message(
|
|
text: Option<String>,
|
|
local_image_paths: Vec<PathBuf>,
|
|
text_elements: Vec<TextElement>,
|
|
) -> Option<UserMessage> {
|
|
let text = text.unwrap_or_default();
|
|
if text.is_empty() && local_image_paths.is_empty() {
|
|
None
|
|
} else {
|
|
let local_images = local_image_paths
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, path)| LocalImageAttachment {
|
|
placeholder: local_image_label_text(idx + 1),
|
|
path,
|
|
})
|
|
.collect();
|
|
Some(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls: Vec::new(),
|
|
text_elements,
|
|
mention_bindings: Vec::new(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn append_text_with_rebased_elements(
|
|
target_text: &mut String,
|
|
target_text_elements: &mut Vec<TextElement>,
|
|
text: &str,
|
|
text_elements: impl IntoIterator<Item = TextElement>,
|
|
) {
|
|
let offset = target_text.len();
|
|
target_text.push_str(text);
|
|
target_text_elements.extend(text_elements.into_iter().map(|mut element| {
|
|
element.byte_range.start += offset;
|
|
element.byte_range.end += offset;
|
|
element
|
|
}));
|
|
}
|
|
|
|
pub(super) fn app_server_text_elements(elements: &[TextElement]) -> Vec<AppServerTextElement> {
|
|
elements.iter().cloned().map(Into::into).collect()
|
|
}
|
|
|
|
fn build_placeholder_mapping(
|
|
local_images: Vec<LocalImageAttachment>,
|
|
next_label: &mut usize,
|
|
) -> (HashMap<String, String>, Vec<LocalImageAttachment>) {
|
|
let mut mapping: HashMap<String, String> = HashMap::new();
|
|
let mut remapped_images = Vec::new();
|
|
for attachment in local_images {
|
|
let new_placeholder = local_image_label_text(*next_label);
|
|
*next_label += 1;
|
|
mapping.insert(attachment.placeholder.clone(), new_placeholder.clone());
|
|
remapped_images.push(LocalImageAttachment {
|
|
placeholder: new_placeholder,
|
|
path: attachment.path,
|
|
});
|
|
}
|
|
(mapping, remapped_images)
|
|
}
|
|
|
|
fn remap_placeholders_in_text(
|
|
text: String,
|
|
text_elements: Vec<TextElement>,
|
|
mapping: &HashMap<String, String>,
|
|
) -> (String, Vec<TextElement>) {
|
|
if mapping.is_empty() {
|
|
return (text, text_elements);
|
|
}
|
|
|
|
let mut elements = text_elements;
|
|
elements.sort_by_key(|elem| elem.byte_range.start);
|
|
|
|
let mut cursor = 0usize;
|
|
let mut rebuilt = String::new();
|
|
let mut rebuilt_elements = Vec::new();
|
|
for mut elem in elements {
|
|
let start = elem.byte_range.start.min(text.len());
|
|
let end = elem.byte_range.end.min(text.len());
|
|
if let Some(segment) = text.get(cursor..start) {
|
|
rebuilt.push_str(segment);
|
|
}
|
|
|
|
let original = text.get(start..end).unwrap_or("");
|
|
let placeholder = elem.placeholder(&text);
|
|
let replacement = placeholder
|
|
.and_then(|ph| mapping.get(ph))
|
|
.map(String::as_str)
|
|
.unwrap_or(original);
|
|
|
|
let elem_start = rebuilt.len();
|
|
rebuilt.push_str(replacement);
|
|
let elem_end = rebuilt.len();
|
|
|
|
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
|
elem.set_placeholder(Some(remapped.clone()));
|
|
}
|
|
elem.byte_range = (elem_start..elem_end).into();
|
|
rebuilt_elements.push(elem);
|
|
cursor = end;
|
|
}
|
|
if let Some(segment) = text.get(cursor..) {
|
|
rebuilt.push_str(segment);
|
|
}
|
|
|
|
(rebuilt, rebuilt_elements)
|
|
}
|
|
|
|
pub(super) fn remap_colliding_paste_placeholders(
|
|
mut message: UserMessage,
|
|
mut pending_pastes: Vec<(String, String)>,
|
|
used: &mut HashSet<String>,
|
|
) -> (UserMessage, Vec<(String, String)>) {
|
|
let mut mapping = HashMap::new();
|
|
for (placeholder, text) in &mut pending_pastes {
|
|
if used.insert(placeholder.clone()) {
|
|
continue;
|
|
}
|
|
|
|
let base = format!("[Pasted Content {} chars]", text.chars().count());
|
|
let mut suffix = 2;
|
|
let replacement = loop {
|
|
let candidate = format!("{base} #{suffix}");
|
|
if used.insert(candidate.clone()) {
|
|
break candidate;
|
|
}
|
|
suffix += 1;
|
|
};
|
|
mapping.insert(placeholder.clone(), replacement.clone());
|
|
*placeholder = replacement;
|
|
}
|
|
(message.text, message.text_elements) =
|
|
remap_placeholders_in_text(message.text, message.text_elements, &mapping);
|
|
(message, pending_pastes)
|
|
}
|
|
|
|
// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering
|
|
// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so
|
|
// the combined local_image_paths order matches the labels, even if placeholders were moved
|
|
// in the text (e.g., [Image #2] appearing before [Image #1]). Apply the same remapping to
|
|
// history overrides so restored drafts and rendered transcript entries agree.
|
|
fn remap_placeholders_for_message_and_history_record(
|
|
message: UserMessage,
|
|
history_record: UserMessageHistoryRecord,
|
|
next_label: &mut usize,
|
|
) -> (UserMessage, UserMessageHistoryRecord) {
|
|
let UserMessage {
|
|
text,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
} = message;
|
|
let (mapping, remapped_images) = build_placeholder_mapping(local_images, next_label);
|
|
let (text, text_elements) = remap_placeholders_in_text(text, text_elements, &mapping);
|
|
let history_record = match history_record {
|
|
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
|
let (text, text_elements) =
|
|
remap_placeholders_in_text(history.text, history.text_elements, &mapping);
|
|
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
|
text,
|
|
text_elements,
|
|
})
|
|
}
|
|
record => record,
|
|
};
|
|
|
|
(
|
|
UserMessage {
|
|
text,
|
|
local_images: remapped_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
},
|
|
history_record,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn remap_placeholders_for_message(
|
|
message: UserMessage,
|
|
next_label: &mut usize,
|
|
) -> UserMessage {
|
|
remap_placeholders_for_message_and_history_record(
|
|
message,
|
|
UserMessageHistoryRecord::UserMessageText,
|
|
next_label,
|
|
)
|
|
.0
|
|
}
|
|
|
|
fn remap_user_messages_with_history_records(
|
|
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
|
) -> Vec<(UserMessage, UserMessageHistoryRecord)> {
|
|
let total_remote_images = messages
|
|
.iter()
|
|
.map(|(message, _)| message.remote_image_urls.len())
|
|
.sum::<usize>();
|
|
let mut next_image_label = total_remote_images + 1;
|
|
messages
|
|
.into_iter()
|
|
.map(|(message, history_record)| {
|
|
remap_placeholders_for_message_and_history_record(
|
|
message,
|
|
history_record,
|
|
&mut next_image_label,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(super) fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
|
|
let messages = remap_user_messages_with_history_records(
|
|
messages
|
|
.into_iter()
|
|
.map(|message| (message, UserMessageHistoryRecord::UserMessageText))
|
|
.collect(),
|
|
);
|
|
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message))
|
|
}
|
|
|
|
fn merge_remapped_user_messages(messages: impl IntoIterator<Item = UserMessage>) -> UserMessage {
|
|
let mut combined = UserMessage {
|
|
text: String::new(),
|
|
text_elements: Vec::new(),
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
};
|
|
|
|
for (idx, message) in messages.into_iter().enumerate() {
|
|
if idx > 0 {
|
|
combined.text.push('\n');
|
|
}
|
|
let UserMessage {
|
|
text,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
} = message;
|
|
append_text_with_rebased_elements(
|
|
&mut combined.text,
|
|
&mut combined.text_elements,
|
|
&text,
|
|
text_elements,
|
|
);
|
|
combined.local_images.extend(local_images);
|
|
combined.remote_image_urls.extend(remote_image_urls);
|
|
combined.mention_bindings.extend(mention_bindings);
|
|
}
|
|
|
|
combined
|
|
}
|
|
|
|
pub(super) fn user_message_for_restore(
|
|
message: UserMessage,
|
|
history_record: &UserMessageHistoryRecord,
|
|
) -> UserMessage {
|
|
match history_record {
|
|
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => UserMessage {
|
|
text: history.text.clone(),
|
|
text_elements: history.text_elements.clone(),
|
|
..message
|
|
},
|
|
UserMessageHistoryRecord::Override(_) | UserMessageHistoryRecord::UserMessageText => {
|
|
message
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn user_message_preview_text(
|
|
message: &UserMessage,
|
|
history_record: Option<&UserMessageHistoryRecord>,
|
|
) -> String {
|
|
match history_record {
|
|
Some(UserMessageHistoryRecord::Override(history)) if !history.text.is_empty() => {
|
|
history.text.clone()
|
|
}
|
|
Some(UserMessageHistoryRecord::Override(_))
|
|
| Some(UserMessageHistoryRecord::UserMessageText)
|
|
| None => message.text.clone(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn user_message_display_for_history(
|
|
message: UserMessage,
|
|
history_record: &UserMessageHistoryRecord,
|
|
) -> UserMessageDisplay {
|
|
let message = user_message_for_restore(message, history_record);
|
|
ChatWidget::user_message_display_from_parts(
|
|
message.text,
|
|
message.text_elements,
|
|
message
|
|
.local_images
|
|
.into_iter()
|
|
.map(|image| image.path)
|
|
.collect(),
|
|
message.remote_image_urls,
|
|
)
|
|
}
|
|
|
|
pub(super) fn merge_user_messages_with_history_record(
|
|
messages: Vec<(UserMessage, UserMessageHistoryRecord)>,
|
|
) -> (UserMessage, UserMessageHistoryRecord) {
|
|
let messages = remap_user_messages_with_history_records(messages);
|
|
let history_record = if messages
|
|
.iter()
|
|
.all(|(_, record)| *record == UserMessageHistoryRecord::UserMessageText)
|
|
{
|
|
UserMessageHistoryRecord::UserMessageText
|
|
} else {
|
|
let mut history_text = String::new();
|
|
let mut history_text_elements = Vec::new();
|
|
let mut history_segment_count = 0usize;
|
|
let mut append_history_segment = |text: &str, text_elements: Vec<TextElement>| {
|
|
if history_segment_count > 0 {
|
|
history_text.push('\n');
|
|
}
|
|
append_text_with_rebased_elements(
|
|
&mut history_text,
|
|
&mut history_text_elements,
|
|
text,
|
|
text_elements,
|
|
);
|
|
history_segment_count += 1;
|
|
};
|
|
for (message, record) in &messages {
|
|
match record {
|
|
UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => {
|
|
append_history_segment(&history.text, history.text_elements.clone());
|
|
}
|
|
UserMessageHistoryRecord::Override(_) if message.text.is_empty() => {}
|
|
UserMessageHistoryRecord::Override(_)
|
|
| UserMessageHistoryRecord::UserMessageText => {
|
|
append_history_segment(&message.text, message.text_elements.clone());
|
|
}
|
|
}
|
|
}
|
|
UserMessageHistoryRecord::Override(UserMessageHistoryOverride {
|
|
text: history_text,
|
|
text_elements: history_text_elements,
|
|
})
|
|
};
|
|
|
|
(
|
|
merge_remapped_user_messages(messages.into_iter().map(|(message, _)| message)),
|
|
history_record,
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) struct UserMessageDisplay {
|
|
pub(super) message: String,
|
|
pub(super) remote_image_urls: Vec<String>,
|
|
pub(super) local_images: Vec<PathBuf>,
|
|
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 user_message_display_from_parts(
|
|
message: String,
|
|
text_elements: Vec<TextElement>,
|
|
local_images: Vec<PathBuf>,
|
|
remote_image_urls: Vec<String>,
|
|
) -> UserMessageDisplay {
|
|
let (message, prompt_request_offset) =
|
|
crate::ide_context::extract_prompt_request_with_offset(&message);
|
|
let prompt_request_end = prompt_request_offset + message.len();
|
|
// Prompt context uses the same delimiter and stripping behavior as the desktop app and IDE
|
|
// extension. The raw user message goes to the agent, but every surface renders only the
|
|
// request after that delimiter, so keep elements inside the visible request and shift their
|
|
// byte ranges to match.
|
|
let text_elements = text_elements
|
|
.into_iter()
|
|
.filter_map(|element| {
|
|
let range = element.byte_range;
|
|
if range.start < prompt_request_offset || range.end > prompt_request_end {
|
|
return None;
|
|
}
|
|
|
|
Some(element.map_range(|range| ByteRange {
|
|
start: range.start - prompt_request_offset,
|
|
end: range.end - prompt_request_offset,
|
|
}))
|
|
})
|
|
.collect();
|
|
|
|
UserMessageDisplay {
|
|
message: message.to_string(),
|
|
remote_image_urls,
|
|
local_images,
|
|
text_elements,
|
|
}
|
|
}
|
|
|
|
/// Build the compare key for a submitted pending steer without invoking the
|
|
/// expensive request-serialization path. Pending steers only need to match the
|
|
/// committed app-server `UserMessage` item emitted after input drains, which
|
|
/// preserves flattened text and total image count.
|
|
pub(super) fn pending_steer_compare_key_from_items(
|
|
items: &[UserInput],
|
|
) -> PendingSteerCompareKey {
|
|
let mut message = String::new();
|
|
let mut image_count = 0;
|
|
|
|
for item in items {
|
|
match item {
|
|
UserInput::Text { text, .. } => message.push_str(text),
|
|
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
|
|
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
|
|
}
|
|
}
|
|
|
|
PendingSteerCompareKey {
|
|
message,
|
|
image_count,
|
|
}
|
|
}
|
|
|
|
pub(super) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay {
|
|
let mut message = String::new();
|
|
let mut remote_image_urls = Vec::new();
|
|
let mut local_images = Vec::new();
|
|
let mut text_elements = Vec::new();
|
|
|
|
for item in items {
|
|
match item {
|
|
UserInput::Text {
|
|
text,
|
|
text_elements: current_text_elements,
|
|
..
|
|
} => append_text_with_rebased_elements(
|
|
&mut message,
|
|
&mut text_elements,
|
|
text,
|
|
current_text_elements.iter().map(|element| {
|
|
let range = element.byte_range.clone();
|
|
TextElement::new(
|
|
range.clone().into(),
|
|
element
|
|
.placeholder()
|
|
.or_else(|| text.get(range.start..range.end))
|
|
.map(str::to_string),
|
|
)
|
|
}),
|
|
),
|
|
UserInput::Image { url, .. } => remote_image_urls.push(url.clone()),
|
|
UserInput::LocalImage { path, .. } => local_images.push(path.clone()),
|
|
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
|
|
}
|
|
}
|
|
|
|
Self::user_message_display_from_parts(
|
|
message,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
)
|
|
}
|
|
}
|