mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## What changed - Retain question drafts, selections, expanded state, and handled message IDs across thread input restoration and reconnects. Preserve buffered live questions during session refresh without replaying their message text. - Enable history recall and search in question answers, honoring updated key bindings and stripping image attachments and placeholders from recalled entries. Preserve Vim mode when an answer is not accepted. - Let forward navigation past the last question restore the latest queued message as an editable main composer draft. Keep working status and queued messages visible alongside questions. - Show a countdown for newly arrived collapsed questions and clear it when the question editor is opened or used. - Disable question submission and skipping while the thread is unavailable. ## Testing Add regression tests and snapshots for draft restoration and replay deduplication, history search and key bindings, queued-message editing, countdown rendering, and question layout with working status. GitOrigin-RevId: de016120a979db860fd0d4b8098b322b312b7d84
776 lines
26 KiB
Rust
776 lines
26 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 codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
|
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
|
|
|
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(crate) questions: Option<crate::bottom_pane::QuestionState>,
|
|
pub(super) composer: Option<ThreadComposerState>,
|
|
pub(super) safety_buffering_prompt: Option<UserMessage>,
|
|
pub(crate) pending_steers: VecDeque<PendingSteer>,
|
|
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(crate) recovered_queue: bool,
|
|
pub(super) user_turn_pending_start: bool,
|
|
pub(super) submit_pending_steers_after_interrupt: 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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct ThreadInputStateRestoreMode {
|
|
pub(crate) preserve_in_flight_turn: 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, Clone, PartialEq)]
|
|
pub(crate) struct PendingSteer {
|
|
/// Preserved across request retries and thread switches until this submission commits.
|
|
pub(crate) client_id: String,
|
|
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(crate) struct UserMessageDisplay {
|
|
pub(crate) message: String,
|
|
pub(crate) remote_image_urls: Vec<String>,
|
|
pub(crate) local_images: Vec<PathBuf>,
|
|
pub(crate) text_elements: Vec<TextElement>,
|
|
}
|
|
|
|
pub(crate) fn mention_bindings_from_user_inputs(
|
|
items: &[UserInput],
|
|
message: &str,
|
|
) -> Vec<MentionBinding> {
|
|
let mention_start = |sigil: char, mention: &str| {
|
|
let token = format!("{sigil}{mention}");
|
|
message.match_indices(&token).find_map(|(start, _)| {
|
|
let end = start + token.len();
|
|
message
|
|
.as_bytes()
|
|
.get(end)
|
|
.is_none_or(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-'))
|
|
.then_some(start)
|
|
})
|
|
};
|
|
let mut mention_bindings: Vec<MentionBinding> = items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
UserInput::Skill { name, path } => Some(MentionBinding {
|
|
sigil: TOOL_MENTION_SIGIL,
|
|
mention: name.clone(),
|
|
path: path.to_string_lossy().into_owned(),
|
|
}),
|
|
UserInput::Mention { name, path } => {
|
|
let plugin_id = path.strip_prefix("plugin://");
|
|
let mention = if let Some(plugin_id) = plugin_id {
|
|
plugin_id
|
|
.split_once('@')
|
|
.map(|(plugin_name, _)| plugin_name)
|
|
.unwrap_or(plugin_id)
|
|
.to_string()
|
|
} else if path.starts_with("app://") {
|
|
codex_connectors::metadata::connector_mention_slug_from_name(name)
|
|
} else {
|
|
name.clone()
|
|
};
|
|
let sigil = if plugin_id.is_some()
|
|
&& mention_start(PLUGIN_TEXT_MENTION_SIGIL, &mention).is_some()
|
|
{
|
|
PLUGIN_TEXT_MENTION_SIGIL
|
|
} else {
|
|
TOOL_MENTION_SIGIL
|
|
};
|
|
Some(MentionBinding {
|
|
sigil,
|
|
mention,
|
|
path: path.clone(),
|
|
})
|
|
}
|
|
UserInput::Text { .. }
|
|
| UserInput::Image { .. }
|
|
| UserInput::LocalImage { .. }
|
|
| UserInput::Audio { .. }
|
|
| UserInput::LocalAudio { .. } => None,
|
|
})
|
|
.collect();
|
|
for item in items {
|
|
let UserInput::Text {
|
|
text,
|
|
text_elements,
|
|
} = item
|
|
else {
|
|
continue;
|
|
};
|
|
for element in text_elements {
|
|
if let Some((mention, path, end)) =
|
|
crate::task_mentions::parse_task_link(text, element.byte_range.start)
|
|
&& end == element.byte_range.end
|
|
&& element
|
|
.placeholder()
|
|
.and_then(|placeholder| placeholder.strip_prefix('@'))
|
|
== Some(mention.as_str())
|
|
{
|
|
mention_bindings.push(MentionBinding {
|
|
sigil: '@',
|
|
mention,
|
|
path,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
mention_bindings.sort_by_key(|binding| {
|
|
let token = if crate::task_mentions::valid_thread_path(&binding.path).is_some() {
|
|
crate::task_mentions::format_task_link(&binding.mention, &binding.path)
|
|
} else {
|
|
format!("{}{}", binding.sigil, binding.mention)
|
|
};
|
|
let mut text_offset = 0;
|
|
items
|
|
.iter()
|
|
.find_map(|item| {
|
|
let UserInput::Text {
|
|
text,
|
|
text_elements,
|
|
} = item
|
|
else {
|
|
return None;
|
|
};
|
|
let offset = text_offset;
|
|
text_offset += text.len();
|
|
text_elements.iter().find_map(|element| {
|
|
(text.get(element.byte_range.start..element.byte_range.end)
|
|
== Some(token.as_str()))
|
|
.then_some(offset + element.byte_range.start)
|
|
})
|
|
})
|
|
.or_else(|| mention_start(binding.sigil, &binding.mention))
|
|
.unwrap_or(usize::MAX)
|
|
});
|
|
mention_bindings
|
|
}
|
|
|
|
#[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 legacy content key for app servers that do not echo submission IDs.
|
|
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::Audio { .. } // TODO: Include audio inputs in pending steer comparison.
|
|
| UserInput::LocalAudio { .. } // TODO: Include audio inputs in pending steer comparison.
|
|
| UserInput::Skill { .. }
|
|
| UserInput::Mention { .. } => {}
|
|
}
|
|
}
|
|
|
|
PendingSteerCompareKey {
|
|
message,
|
|
image_count,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay {
|
|
if items
|
|
.iter()
|
|
.any(|item| matches!(item, UserInput::Audio { .. } | UserInput::LocalAudio { .. }))
|
|
{
|
|
tracing::warn!("audio user inputs are not supported by the TUI and will be omitted");
|
|
}
|
|
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::Audio { .. } // TODO: Include audio inputs in the user message display.
|
|
| UserInput::LocalAudio { .. } // TODO: Include audio inputs in the user message display.
|
|
| UserInput::Skill { .. }
|
|
| UserInput::Mention { .. } => {}
|
|
}
|
|
}
|
|
|
|
(message, text_elements) = crate::task_mentions::decode_task_links(&message, text_elements);
|
|
|
|
Self::user_message_display_from_parts(
|
|
message,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
)
|
|
}
|
|
}
|