mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
6 Commits
maxj/threa
...
text-eleme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649a456fb0 | ||
|
|
70424769d1 | ||
|
|
a0e778795c | ||
|
|
ef828ac65a | ||
|
|
2821aef611 | ||
|
|
e50f924e4f |
@@ -423,6 +423,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -446,6 +448,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -469,6 +473,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -666,6 +672,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// New sessions start without prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -716,6 +724,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// Fork/resume bootstraps here don't carry any prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -785,6 +795,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// Fork/resume bootstraps here don't carry any prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -1992,6 +2004,8 @@ mod tests {
|
||||
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
message: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
|
||||
@@ -127,7 +127,9 @@ impl App {
|
||||
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
|
||||
self.trim_transcript_for_backtrack(selection.nth_user_message);
|
||||
if !selection.prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(selection.prefill);
|
||||
// TODO: Thread prefill text elements and local images through backtrack.
|
||||
self.chat_widget
|
||||
.set_composer_text(selection.prefill, Vec::new(), Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +416,8 @@ mod tests {
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first user".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -430,6 +434,8 @@ mod tests {
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -458,11 +464,15 @@ mod tests {
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "second".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
|
||||
@@ -108,9 +108,12 @@ use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
@@ -140,8 +143,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
/// Result returned when the user interacts with the text area.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Queued(String),
|
||||
Submitted {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Queued {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
None,
|
||||
@@ -323,7 +332,8 @@ impl ChatComposer {
|
||||
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
|
||||
return false;
|
||||
};
|
||||
self.set_text_content(text);
|
||||
// History lookup returns plain text only; no UI element ranges or attachments to restore.
|
||||
self.set_text_content(text, Vec::new(), Vec::new());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -500,12 +510,31 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Replace the entire composer content with `text` and reset cursor.
|
||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||
pub(crate) fn set_text_content(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
self.textarea.set_text("");
|
||||
self.pending_pastes.clear();
|
||||
self.attached_images.clear();
|
||||
self.textarea.set_text(&text);
|
||||
|
||||
self.textarea.set_text_with_elements(&text, &text_elements);
|
||||
|
||||
let image_placeholders: HashSet<String> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| elem.placeholder.clone())
|
||||
.collect();
|
||||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||||
let placeholder = local_image_label_text(idx + 1);
|
||||
if image_placeholders.contains(&placeholder) {
|
||||
self.attached_images
|
||||
.push(AttachedImage { placeholder, path });
|
||||
}
|
||||
}
|
||||
|
||||
self.textarea.set_cursor(0);
|
||||
self.sync_popups();
|
||||
}
|
||||
@@ -515,7 +544,7 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
let previous = self.current_text();
|
||||
self.set_text_content(String::new());
|
||||
self.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
self.history.reset_navigation();
|
||||
self.history.record_local_submission(&previous);
|
||||
Some(previous)
|
||||
@@ -526,6 +555,28 @@ impl ChatComposer {
|
||||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
|
||||
self.textarea.text_elements()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
|
||||
self.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn local_images(&self) -> Vec<LocalImageAttachment> {
|
||||
self.attached_images
|
||||
.iter()
|
||||
.map(|img| LocalImageAttachment {
|
||||
placeholder: img.placeholder.clone(),
|
||||
path: img.path.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert an attachment placeholder and track it for the next submission.
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
@@ -537,11 +588,23 @@ impl ChatComposer {
|
||||
.push(AttachedImage { placeholder, path });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
let images = std::mem::take(&mut self.attached_images);
|
||||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec<LocalImageAttachment> {
|
||||
let images = std::mem::take(&mut self.attached_images);
|
||||
images
|
||||
.into_iter()
|
||||
.map(|img| LocalImageAttachment {
|
||||
placeholder: img.placeholder,
|
||||
path: img.path,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Flushes any due paste-burst state.
|
||||
///
|
||||
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
|
||||
@@ -773,7 +836,14 @@ impl ChatComposer {
|
||||
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(expanded), true);
|
||||
return (
|
||||
InputResult::Submitted {
|
||||
text: expanded,
|
||||
// Expanded prompt is plain text; no UI element ranges to preserve.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
@@ -791,7 +861,14 @@ impl ChatComposer {
|
||||
) {
|
||||
PromptSelectionAction::Submit { text } => {
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(text), true);
|
||||
return (
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
// Prompt submission has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
PromptSelectionAction::Insert { text, cursor } => {
|
||||
let target = cursor.unwrap_or(text.len());
|
||||
@@ -1098,6 +1175,42 @@ impl ChatComposer {
|
||||
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
||||
}
|
||||
|
||||
fn trim_text_elements(
|
||||
original: &str,
|
||||
trimmed: &str,
|
||||
elements: Vec<TextElement>,
|
||||
) -> Vec<TextElement> {
|
||||
if trimmed.is_empty() || elements.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let trimmed_start = original.len().saturating_sub(original.trim_start().len());
|
||||
let trimmed_end = trimmed_start.saturating_add(trimmed.len());
|
||||
|
||||
elements
|
||||
.into_iter()
|
||||
.filter_map(|elem| {
|
||||
let start = elem.byte_range.start;
|
||||
let end = elem.byte_range.end;
|
||||
if end <= trimmed_start || start >= trimmed_end {
|
||||
return None;
|
||||
}
|
||||
let new_start = start.saturating_sub(trimmed_start);
|
||||
let new_end = end.saturating_sub(trimmed_start).min(trimmed.len());
|
||||
if new_start >= new_end {
|
||||
return None;
|
||||
}
|
||||
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
|
||||
Some(TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn skills_enabled(&self) -> bool {
|
||||
self.skills.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
@@ -1313,7 +1426,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||||
fn prepare_submission_text(&mut self) -> Option<String> {
|
||||
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
@@ -1329,6 +1442,13 @@ impl ChatComposer {
|
||||
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut text_elements = original_text_elements.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
@@ -1343,6 +1463,7 @@ impl ChatComposer {
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
text_elements = Self::trim_text_elements(&original_input, &text, text_elements);
|
||||
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
@@ -1369,8 +1490,13 @@ impl ChatComposer {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1382,13 +1508,20 @@ impl ChatComposer {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
// Expanded prompt (e.g. custom prompt) is plain text; no UI element ranges to preserve.
|
||||
text_elements = Vec::new();
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return None;
|
||||
@@ -1396,7 +1529,7 @@ impl ChatComposer {
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
Some(text)
|
||||
Some((text, text_elements))
|
||||
}
|
||||
|
||||
/// Common logic for handling message submission/queuing.
|
||||
@@ -1445,20 +1578,44 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(result) = self.try_dispatch_slash_command_with_args() {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let Some(text) = self.prepare_submission_text() {
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text() {
|
||||
if should_queue {
|
||||
(InputResult::Queued(text), true)
|
||||
(
|
||||
InputResult::Queued {
|
||||
text,
|
||||
text_elements,
|
||||
},
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
(
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Restore text if submission was suppressed
|
||||
self.textarea.set_text(&original_input);
|
||||
// Restore text if submission was suppressed.
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
}
|
||||
@@ -1555,7 +1712,7 @@ impl ChatComposer {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(text) = replace_text {
|
||||
self.set_text_content(text);
|
||||
self.set_text_content(text, Vec::new(), Vec::new());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
@@ -1706,15 +1863,6 @@ impl ChatComposer {
|
||||
{
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
// Backspace at the start of an image placeholder should delete that placeholder (rather
|
||||
// than deleting content before it). Do this without scanning the full text by consulting
|
||||
// the textarea's element list.
|
||||
if matches!(input.code, KeyCode::Backspace)
|
||||
&& self.try_remove_image_element_at_cursor_start()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// For non-char inputs (or after flushing), handle normally.
|
||||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||||
@@ -1753,29 +1901,6 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
|
||||
if self.attached_images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let p = self.textarea.cursor();
|
||||
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
|
||||
return false;
|
||||
};
|
||||
let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == payload)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.textarea.replace_range(p..p + payload.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
@@ -2230,7 +2355,7 @@ impl Renderable for ChatComposer {
|
||||
.unwrap_or("Input disabled.")
|
||||
.to_string()
|
||||
};
|
||||
let placeholder = Span::from(text).dim();
|
||||
let placeholder = Span::from(text).dim().italic();
|
||||
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
}
|
||||
}
|
||||
@@ -2487,7 +2612,7 @@ mod tests {
|
||||
composer.set_steer_enabled(true);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_text_content("draft text".to_string());
|
||||
composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new());
|
||||
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
|
||||
assert!(composer.is_empty());
|
||||
|
||||
@@ -2794,7 +2919,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
@@ -3116,7 +3241,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "hello"),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, "hello"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
@@ -3182,7 +3307,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, large),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, large),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
@@ -3455,10 +3580,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/init'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/init'"),
|
||||
@@ -3534,10 +3659,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/diff'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch after Tab completion, got literal queue")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/diff'"),
|
||||
@@ -3573,10 +3698,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/mention'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/mention'"),
|
||||
@@ -3661,7 +3786,7 @@ mod tests {
|
||||
// Submit and verify final expansion
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -3874,7 +3999,7 @@ mod tests {
|
||||
|
||||
// --- Image attachment tests ---
|
||||
#[test]
|
||||
fn attach_image_and_submit_includes_image_paths() {
|
||||
fn attach_image_and_submit_includes_local_image_paths() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -3891,7 +4016,21 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1] hi");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: "[Image #1]".len()
|
||||
}
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3915,7 +4054,21 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1]");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: "[Image #1]".len()
|
||||
}
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3962,6 +4115,24 @@ mod tests {
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 0: backspace at start should remove the preceding character, not the placeholder.
|
||||
composer.set_text_content("A".to_string(), Vec::new(), Vec::new());
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder0 = composer.attached_images[0].placeholder.clone();
|
||||
let start0 = composer
|
||||
.textarea
|
||||
.text()
|
||||
.find(&placeholder0)
|
||||
.expect("placeholder present");
|
||||
composer.textarea.set_cursor(start0);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert_eq!(composer.textarea.text(), placeholder0);
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
|
||||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
composer.textarea.move_cursor_to_end_of_line(false);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
@@ -4070,6 +4241,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_reordered_image_one_renumbers_text_in_place() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let path1 = PathBuf::from("/tmp/image_first.png");
|
||||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||||
let placeholder1 = local_image_label_text(1);
|
||||
let placeholder2 = local_image_label_text(2);
|
||||
|
||||
// Placeholders can be reordered in the text buffer; deleting image #1 should renumber
|
||||
// image #2 wherever it appears, not just after the cursor.
|
||||
let text = format!("Test {placeholder2} test {placeholder1}");
|
||||
let start2 = text.find(&placeholder2).expect("placeholder2 present");
|
||||
let start1 = text.find(&placeholder1).expect("placeholder1 present");
|
||||
let text_elements = vec![
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: start2,
|
||||
end: start2 + placeholder2.len(),
|
||||
},
|
||||
placeholder: Some(placeholder2),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: start1,
|
||||
end: start1 + placeholder1.len(),
|
||||
},
|
||||
placeholder: Some(placeholder1.clone()),
|
||||
},
|
||||
];
|
||||
composer.set_text_content(text, text_elements, vec![path1, path2.clone()]);
|
||||
|
||||
let end1 = start1 + placeholder1.len();
|
||||
composer.textarea.set_cursor(end1);
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
composer.textarea.text(),
|
||||
format!("Test {placeholder1} test ")
|
||||
);
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: placeholder1
|
||||
}],
|
||||
composer.attached_images,
|
||||
"attachment renumbered after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_first_text_element_renumbers_following_text_element() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -4167,7 +4401,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == prompt_text
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4199,10 +4436,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Review Alice changes on main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Review Alice changes on main"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4234,10 +4472,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Pair Alice Smith with dev-main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Pair Alice Smith with dev-main"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4294,7 +4533,7 @@ mod tests {
|
||||
|
||||
// Verify the custom prompt was expanded with the large content as positional arg
|
||||
match result {
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
// The prompt should be expanded, with the large content replacing $1
|
||||
assert_eq!(
|
||||
text,
|
||||
@@ -4332,7 +4571,7 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, "/Users/example/project/src/main.rs");
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -4367,7 +4606,7 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, "/this-looks-like-a-command");
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -4516,7 +4755,10 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
|
||||
assert_eq!(InputResult::Submitted(expected), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == expected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4547,7 +4789,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == "Echo: hi"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4619,10 +4864,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Cost: $$ and first: x".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Cost: $$ and first: x"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4659,7 +4905,10 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let expected = "First: one two\nSecond: one two".to_string();
|
||||
assert_eq!(InputResult::Submitted(expected), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == expected
|
||||
));
|
||||
}
|
||||
|
||||
/// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst
|
||||
@@ -4813,7 +5062,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Simulate history-like content: "/ test"
|
||||
composer.set_text_content("/ test".to_string());
|
||||
composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
// After set_text_content -> sync_popups is called; popup should NOT be Command.
|
||||
assert!(
|
||||
@@ -4843,21 +5092,21 @@ mod tests {
|
||||
);
|
||||
|
||||
// Case 1: bare "/"
|
||||
composer.set_text_content("/".to_string());
|
||||
composer.set_text_content("/".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"bare '/' should activate slash popup"
|
||||
);
|
||||
|
||||
// Case 2: valid prefix "/re" (matches /review, /resume, etc.)
|
||||
composer.set_text_content("/re".to_string());
|
||||
composer.set_text_content("/re".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"'/re' should activate slash popup via prefix match"
|
||||
);
|
||||
|
||||
// Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback)
|
||||
composer.set_text_content("/ac".to_string());
|
||||
composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"'/ac' should activate slash popup via fuzzy match"
|
||||
@@ -4866,7 +5115,7 @@ mod tests {
|
||||
// Case 4: invalid prefix "/zzz" – still allowed to open popup if it
|
||||
// matches no built-in command; our current logic will not open popup.
|
||||
// Verify that explicitly.
|
||||
composer.set_text_content("/zzz".to_string());
|
||||
composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::None),
|
||||
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
|
||||
@@ -5001,7 +5250,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_text_content("hello".to_string());
|
||||
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_input_enabled(false, Some("Input disabled for test.".to_string()));
|
||||
|
||||
let (result, needs_redraw) =
|
||||
|
||||
@@ -28,6 +28,7 @@ use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::features::Features;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -38,6 +39,12 @@ mod approval_overlay;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
mod bottom_pane_view;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LocalImageAttachment {
|
||||
pub(crate) placeholder: String,
|
||||
pub(crate) path: PathBuf,
|
||||
}
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
@@ -309,8 +316,14 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.composer.set_text_content(text);
|
||||
pub(crate) fn set_composer_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
self.composer
|
||||
.set_text_content(text, text_elements, local_image_paths);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -334,6 +347,19 @@ impl BottomPane {
|
||||
self.composer.current_text()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
|
||||
self.composer.text_elements()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_local_images(&self) -> Vec<LocalImageAttachment> {
|
||||
self.composer.local_images()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn composer_local_image_paths(&self) -> Vec<PathBuf> {
|
||||
self.composer.local_image_paths()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_text_with_pending(&self) -> String {
|
||||
self.composer.current_text_with_pending()
|
||||
}
|
||||
@@ -627,10 +653,18 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
self.composer.take_recent_submission_images()
|
||||
}
|
||||
|
||||
pub(crate) fn take_recent_submission_images_with_placeholders(
|
||||
&mut self,
|
||||
) -> Vec<LocalImageAttachment> {
|
||||
self.composer
|
||||
.take_recent_submission_images_with_placeholders()
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::key_hint::is_altgr;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -60,10 +62,33 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the textarea text and clear any existing text elements.
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
self.set_text_inner(text, None);
|
||||
}
|
||||
|
||||
/// Replace the textarea text and set the provided text elements.
|
||||
pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) {
|
||||
self.set_text_inner(text, Some(elements));
|
||||
}
|
||||
|
||||
fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
|
||||
self.text = text.to_string();
|
||||
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
||||
self.elements.clear();
|
||||
if let Some(elements) = elements {
|
||||
for elem in elements {
|
||||
let mut start = elem.byte_range.start.min(self.text.len());
|
||||
let mut end = elem.byte_range.end.min(self.text.len());
|
||||
start = self.clamp_pos_to_char_boundary(start);
|
||||
end = self.clamp_pos_to_char_boundary(end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
self.elements.push(TextElement { range: start..end });
|
||||
}
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
self.wrap_cache.replace(None);
|
||||
self.preferred_col = None;
|
||||
@@ -722,6 +747,22 @@ impl TextArea {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn text_elements(&self) -> Vec<UserTextElement> {
|
||||
self.elements
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let placeholder = self.text.get(e.range.clone()).map(str::to_string);
|
||||
UserTextElement {
|
||||
byte_range: ByteRange {
|
||||
start: e.range.start,
|
||||
end: e.range.end,
|
||||
},
|
||||
placeholder,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
|
||||
let pos = pos.min(self.text.len());
|
||||
let elem = self.elements.iter().find(|e| e.range.start == pos)?;
|
||||
|
||||
@@ -92,7 +92,9 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -128,6 +130,7 @@ use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
@@ -347,6 +350,7 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) initial_prompt: Option<String>,
|
||||
pub(crate) initial_images: Vec<PathBuf>,
|
||||
pub(crate) initial_text_elements: Vec<TextElement>,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
@@ -508,14 +512,17 @@ pub(crate) struct ActiveCellTranscriptKey {
|
||||
|
||||
struct UserMessage {
|
||||
text: String,
|
||||
image_paths: Vec<PathBuf>,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
image_paths: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,19 +531,104 @@ impl From<&str> for UserMessage {
|
||||
fn from(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
image_paths: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
fn create_initial_user_message(
|
||||
text: String,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> Option<UserMessage> {
|
||||
if text.is_empty() && local_image_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(UserMessage { text, image_paths })
|
||||
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,
|
||||
text_elements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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]).
|
||||
fn remap_placeholders_for_message(
|
||||
text: &str,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
next_label: &mut usize,
|
||||
) -> (String, Vec<TextElement>, Vec<LocalImageAttachment>) {
|
||||
if local_images.is_empty() {
|
||||
return (text.to_string(), text_elements, Vec::new());
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 replacement = elem
|
||||
.placeholder
|
||||
.as_ref()
|
||||
.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(placeholder) = elem.placeholder.as_ref()
|
||||
&& let Some(remapped) = mapping.get(placeholder)
|
||||
{
|
||||
elem.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, remapped_images)
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
|
||||
///
|
||||
@@ -993,22 +1085,65 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
// If any messages were queued during the task, restore them into the composer.
|
||||
// Subtlety: each queued draft numbers its attachments from [Image #1], so when we
|
||||
// concatenate multiple drafts we must reassign placeholders in a stable order so the
|
||||
// merged attachment list matches the labels in the combined text.
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
let queued_text = self
|
||||
let existing_text = self.bottom_pane.composer_text();
|
||||
let existing_text_elements = self.bottom_pane.composer_text_elements();
|
||||
let existing_local_images = self.bottom_pane.composer_local_images();
|
||||
let mut combined_text = String::new();
|
||||
let mut combined_text_elements = Vec::new();
|
||||
let mut combined_local_images = Vec::new();
|
||||
let mut combined_offset = 0usize;
|
||||
let mut next_image_label = 1usize;
|
||||
|
||||
let mut to_merge: Vec<(String, Vec<TextElement>, Vec<LocalImageAttachment>)> = self
|
||||
.queued_user_messages
|
||||
.iter()
|
||||
.map(|m| m.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let existing_text = self.bottom_pane.composer_text();
|
||||
let combined = if existing_text.is_empty() {
|
||||
queued_text
|
||||
} else if queued_text.is_empty() {
|
||||
existing_text
|
||||
} else {
|
||||
format!("{queued_text}\n{existing_text}")
|
||||
};
|
||||
self.bottom_pane.set_composer_text(combined);
|
||||
.map(|message| {
|
||||
(
|
||||
message.text.clone(),
|
||||
message.text_elements.clone(),
|
||||
message.local_images.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !existing_text.is_empty() || !existing_local_images.is_empty() {
|
||||
to_merge.push((existing_text, existing_text_elements, existing_local_images));
|
||||
}
|
||||
|
||||
for (idx, (text, elements, local_images)) in to_merge.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
combined_text.push('\n');
|
||||
combined_offset += 1;
|
||||
}
|
||||
let (text, elements, local_images) = remap_placeholders_for_message(
|
||||
&text,
|
||||
elements,
|
||||
local_images,
|
||||
&mut next_image_label,
|
||||
);
|
||||
let base = combined_offset;
|
||||
combined_text.push_str(&text);
|
||||
combined_offset += text.len();
|
||||
combined_text_elements.extend(elements.into_iter().map(|mut elem| {
|
||||
elem.byte_range.start += base;
|
||||
elem.byte_range.end += base;
|
||||
elem
|
||||
}));
|
||||
combined_local_images.extend(local_images);
|
||||
}
|
||||
|
||||
let combined_local_image_paths = combined_local_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_composer_text(
|
||||
combined_text,
|
||||
combined_text_elements,
|
||||
combined_local_image_paths,
|
||||
);
|
||||
// Clear the queue and update the status indicator list.
|
||||
self.queued_user_messages.clear();
|
||||
self.refresh_queued_user_messages();
|
||||
@@ -1629,6 +1764,7 @@ impl ChatWidget {
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -1677,6 +1813,7 @@ impl ChatWidget {
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
),
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -1735,6 +1872,7 @@ impl ChatWidget {
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -1775,6 +1913,7 @@ impl ChatWidget {
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
),
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -1889,46 +2028,64 @@ impl ChatWidget {
|
||||
} if !self.queued_user_messages.is_empty() => {
|
||||
// Prefer the most recently queued item.
|
||||
if let Some(user_message) = self.queued_user_messages.pop_back() {
|
||||
self.bottom_pane.set_composer_text(user_message.text);
|
||||
let local_image_paths = user_message
|
||||
.local_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_composer_text(
|
||||
user_message.text,
|
||||
user_message.text_elements,
|
||||
local_image_paths,
|
||||
);
|
||||
self.refresh_queued_user_messages();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// Enter always sends messages immediately (bypasses queue check)
|
||||
// Clear any reasoning status header when submitting a new message
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
if !self.is_session_configured() {
|
||||
self.queue_user_message(user_message);
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
InputResult::Queued(text) => {
|
||||
// Tab queues the message if a task is running, otherwise submits immediately
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
_ => match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
// Enter always sends messages immediately (bypasses queue check)
|
||||
// Clear any reasoning status header when submitting a new message
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements,
|
||||
};
|
||||
if !self.is_session_configured() {
|
||||
self.queue_user_message(user_message);
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
}
|
||||
InputResult::Queued {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements,
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2251,8 +2408,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
let UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
text_elements,
|
||||
} = user_message;
|
||||
if text.is_empty() && local_images.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2276,15 +2437,16 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
for path in image_paths {
|
||||
items.push(UserInput::LocalImage { path });
|
||||
for image in &local_images {
|
||||
items.push(UserInput::LocalImage {
|
||||
path: image.path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
// TODO: Thread text element ranges from the composer input. Empty keeps old behavior.
|
||||
items.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
text_elements: text_elements.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2318,7 +2480,12 @@ impl ChatWidget {
|
||||
|
||||
// Only show the text portion in conversation history.
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text));
|
||||
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
));
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = false;
|
||||
@@ -2533,9 +2700,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||||
let message = event.message.trim();
|
||||
if !message.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
|
||||
if !event.message.trim().is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
event.message,
|
||||
event.text_elements,
|
||||
event.local_images,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4117,8 +4287,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
/// Replace the composer content with the provided text and reset cursor.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.bottom_pane.set_composer_text(text);
|
||||
pub(crate) fn set_composer_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
self.bottom_pane
|
||||
.set_composer_text(text, text_elements, local_image_paths);
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
|
||||
@@ -8,6 +8,8 @@ use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
@@ -66,6 +68,8 @@ use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -180,6 +184,298 @@ async fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let message = format!("{placeholder} replayed");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let local_images = vec![PathBuf::from("/tmp/replay.png")];
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: message.clone(),
|
||||
images: None,
|
||||
text_elements: text_elements.clone(),
|
||||
local_images: local_images.clone(),
|
||||
})]),
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((
|
||||
cell.message.clone(),
|
||||
cell.text_elements.clone(),
|
||||
cell.local_image_paths.clone(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_message, stored_elements, stored_images) =
|
||||
user_cell.expect("expected a replayed user history cell");
|
||||
assert_eq!(stored_message, message);
|
||||
assert_eq!(stored_elements, text_elements);
|
||||
assert_eq!(stored_images, local_images);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_preserves_text_elements_and_local_images() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let text = format!("{placeholder} submit");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let local_images = vec![PathBuf::from("/tmp/submitted.png")];
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text(text.clone(), text_elements.clone(), local_images.clone());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let mut user_input_items = None;
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if let Op::UserInput { items, .. } = op {
|
||||
user_input_items = Some(items);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let items = user_input_items.expect("expected Op::UserInput");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(
|
||||
items[0],
|
||||
UserInput::LocalImage {
|
||||
path: local_images[0].clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[1],
|
||||
UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: text_elements.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((
|
||||
cell.message.clone(),
|
||||
cell.text_elements.clone(),
|
||||
cell.local_image_paths.clone(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_message, stored_elements, stored_images) =
|
||||
user_cell.expect("expected submitted user history cell");
|
||||
assert_eq!(stored_message, text);
|
||||
assert_eq!(stored_elements, text_elements);
|
||||
assert_eq!(stored_images, local_images);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let first_placeholder = "[Image #1]";
|
||||
let first_text = format!("{first_placeholder} first");
|
||||
let first_elements = vec![TextElement {
|
||||
byte_range: (0..first_placeholder.len()).into(),
|
||||
placeholder: Some(first_placeholder.to_string()),
|
||||
}];
|
||||
let first_images = [PathBuf::from("/tmp/first.png")];
|
||||
|
||||
let second_placeholder = "[Image #1]";
|
||||
let second_text = format!("{second_placeholder} second");
|
||||
let second_elements = vec![TextElement {
|
||||
byte_range: (0..second_placeholder.len()).into(),
|
||||
placeholder: Some(second_placeholder.to_string()),
|
||||
}];
|
||||
let second_images = [PathBuf::from("/tmp/second.png")];
|
||||
|
||||
let existing_placeholder = "[Image #1]";
|
||||
let existing_text = format!("{existing_placeholder} existing");
|
||||
let existing_elements = vec![TextElement {
|
||||
byte_range: (0..existing_placeholder.len()).into(),
|
||||
placeholder: Some(existing_placeholder.to_string()),
|
||||
}];
|
||||
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
|
||||
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
text: first_text,
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: first_placeholder.to_string(),
|
||||
path: first_images[0].clone(),
|
||||
}],
|
||||
text_elements: first_elements,
|
||||
});
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
text: second_text,
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: second_placeholder.to_string(),
|
||||
path: second_images[0].clone(),
|
||||
}],
|
||||
text_elements: second_elements,
|
||||
});
|
||||
chat.refresh_queued_user_messages();
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text(existing_text, existing_elements, existing_images.clone());
|
||||
|
||||
// When interrupted, queued messages are merged into the composer; image placeholders
|
||||
// must be renumbered to match the combined local image list.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "interrupt".into(),
|
||||
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}),
|
||||
});
|
||||
|
||||
let first = "[Image #1] first".to_string();
|
||||
let second = "[Image #2] second".to_string();
|
||||
let third = "[Image #3] existing".to_string();
|
||||
let expected_text = format!("{first}\n{second}\n{third}");
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected_text);
|
||||
|
||||
let first_start = 0;
|
||||
let second_start = first.len() + 1;
|
||||
let third_start = second_start + second.len() + 1;
|
||||
let expected_elements = vec![
|
||||
TextElement {
|
||||
byte_range: (first_start..first_start + "[Image #1]".len()).into(),
|
||||
placeholder: Some("[Image #1]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (second_start..second_start + "[Image #2]".len()).into(),
|
||||
placeholder: Some("[Image #2]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (third_start..third_start + "[Image #3]".len()).into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
];
|
||||
assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_local_image_paths(),
|
||||
vec![
|
||||
first_images[0].clone(),
|
||||
second_images[0].clone(),
|
||||
existing_images[0].clone(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remap_placeholders_uses_attachment_labels() {
|
||||
let placeholder_one = "[Image #1]";
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: Some(placeholder_two.to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: Some(placeholder_one.to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
LocalImageAttachment {
|
||||
placeholder: placeholder_one.to_string(),
|
||||
path: PathBuf::from("/tmp/one.png"),
|
||||
},
|
||||
LocalImageAttachment {
|
||||
placeholder: placeholder_two.to_string(),
|
||||
path: PathBuf::from("/tmp/two.png"),
|
||||
},
|
||||
];
|
||||
let mut next_label = 3usize;
|
||||
let (remapped, remapped_elements, remapped_images) =
|
||||
remap_placeholders_for_message(&text, elements, attachments, &mut next_label);
|
||||
|
||||
assert_eq!(remapped, "[Image #4] before [Image #3]");
|
||||
assert_eq!(
|
||||
remapped_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remapped_images,
|
||||
vec![
|
||||
LocalImageAttachment {
|
||||
placeholder: "[Image #3]".to_string(),
|
||||
path: PathBuf::from("/tmp/one.png"),
|
||||
},
|
||||
LocalImageAttachment {
|
||||
placeholder: "[Image #4]".to_string(),
|
||||
path: PathBuf::from("/tmp/two.png"),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Entering review mode uses the hint provided by the review request.
|
||||
#[tokio::test]
|
||||
async fn entered_review_mode_uses_request_hint() {
|
||||
@@ -352,6 +648,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
app_event_tx: tx,
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: false,
|
||||
auth_manager,
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -1063,7 +1360,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
// Submit an initial prompt to seed history.
|
||||
chat.bottom_pane.set_composer_text("repeat me".to_string());
|
||||
chat.bottom_pane
|
||||
.set_composer_text("repeat me".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Simulate an active task so further submissions are queued.
|
||||
@@ -1097,7 +1395,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
assert!(chat.bottom_pane.status_widget().is_none());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string());
|
||||
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
@@ -2748,7 +3046,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
|
||||
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("current draft".to_string());
|
||||
.set_composer_text("current draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("first queued".to_string()));
|
||||
@@ -2796,26 +3094,6 @@ async fn interrupt_clears_unified_exec_processes() {
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_complete_clears_unified_exec_processes() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
|
||||
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
||||
assert_eq!(chat.unified_exec_processes.len(), 2);
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "turn-1".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert!(chat.unified_exec_processes.is_empty());
|
||||
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
}
|
||||
|
||||
// Snapshot test: ChatWidget at very small heights (idle)
|
||||
// Ensures overall layout behaves when terminal height is extremely constrained.
|
||||
#[tokio::test]
|
||||
@@ -3772,8 +4050,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
delta: "**Investigating rendering code**".into(),
|
||||
}),
|
||||
});
|
||||
chat.bottom_pane
|
||||
.set_composer_text("Summarize recent commits".to_string());
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"Summarize recent commits".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width: u16 = 80;
|
||||
let ui_height: u16 = chat.desired_height(width);
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
@@ -54,6 +55,7 @@ use mcp_types::Resource;
|
||||
use mcp_types::ResourceLink;
|
||||
use mcp_types::ResourceTemplate;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
@@ -158,6 +160,8 @@ impl dyn HistoryCell {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UserHistoryCell {
|
||||
pub message: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
pub local_image_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl HistoryCell for UserHistoryCell {
|
||||
@@ -171,13 +175,75 @@ impl HistoryCell for UserHistoryCell {
|
||||
.max(1);
|
||||
|
||||
let style = user_message_style();
|
||||
let element_style = style.fg(Color::Cyan);
|
||||
|
||||
let wrapped = word_wrap_lines(
|
||||
self.message.lines().map(|l| Line::from(l).style(style)),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
);
|
||||
let wrapped = if self.text_elements.is_empty() {
|
||||
word_wrap_lines(
|
||||
self.message.lines().map(|l| Line::from(l).style(style)),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
)
|
||||
} else {
|
||||
let mut elements = self.text_elements.clone();
|
||||
elements.sort_by_key(|e| e.byte_range.start);
|
||||
let mut offset = 0usize;
|
||||
let mut raw_lines: Vec<Line<'static>> = Vec::new();
|
||||
for line_text in self.message.lines() {
|
||||
let line_start = offset;
|
||||
let line_end = line_start + line_text.len();
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
// Track how much of the line we've emitted to interleave plain and styled spans.
|
||||
let mut cursor = line_start;
|
||||
for elem in &elements {
|
||||
let start = elem.byte_range.start.max(line_start);
|
||||
let end = elem.byte_range.end.min(line_end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let rel_start = start - line_start;
|
||||
let rel_end = end - line_start;
|
||||
// Guard against malformed UTF-8 byte ranges from upstream data; skip
|
||||
// invalid elements rather than panicking while rendering history.
|
||||
if !line_text.is_char_boundary(rel_start)
|
||||
|| !line_text.is_char_boundary(rel_end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rel_cursor = cursor - line_start;
|
||||
if cursor < start
|
||||
&& line_text.is_char_boundary(rel_cursor)
|
||||
&& let Some(segment) = line_text.get(rel_cursor..rel_start)
|
||||
{
|
||||
spans.push(Span::from(segment.to_string()));
|
||||
}
|
||||
if let Some(segment) = line_text.get(rel_start..rel_end) {
|
||||
spans.push(Span::styled(segment.to_string(), element_style));
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
let rel_cursor = cursor - line_start;
|
||||
if cursor < line_end
|
||||
&& line_text.is_char_boundary(rel_cursor)
|
||||
&& let Some(segment) = line_text.get(rel_cursor..)
|
||||
{
|
||||
spans.push(Span::from(segment.to_string()));
|
||||
}
|
||||
let line = if spans.is_empty() {
|
||||
Line::from(line_text.to_string()).style(style)
|
||||
} else {
|
||||
Line::from(spans).style(style)
|
||||
};
|
||||
raw_lines.push(line);
|
||||
// TextArea normalizes newlines to '\n', so advancing by 1 is correct.
|
||||
offset = line_end + 1;
|
||||
}
|
||||
word_wrap_lines(
|
||||
raw_lines,
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
)
|
||||
};
|
||||
|
||||
lines.push(Line::from("").style(style));
|
||||
lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into()));
|
||||
@@ -886,8 +952,16 @@ pub(crate) fn new_session_info(
|
||||
SessionInfoCell(CompositeHistoryCell { parts })
|
||||
}
|
||||
|
||||
pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
|
||||
UserHistoryCell { message }
|
||||
pub(crate) fn new_user_prompt(
|
||||
message: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) -> UserHistoryCell {
|
||||
UserHistoryCell {
|
||||
message,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -2581,6 +2655,8 @@ mod tests {
|
||||
let msg = "one two three four five six seven";
|
||||
let cell = UserHistoryCell {
|
||||
message: msg.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
};
|
||||
|
||||
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
|
||||
|
||||
@@ -48,13 +48,14 @@ impl ComposerInput {
|
||||
|
||||
/// Clear the input text.
|
||||
pub fn clear(&mut self) {
|
||||
self.inner.set_text_content(String::new());
|
||||
self.inner
|
||||
.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
/// Feed a key event into the composer and return a high-level action.
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted(text) => ComposerAction::Submitted(text),
|
||||
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
|
||||
@@ -481,6 +481,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -504,6 +506,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -527,6 +531,8 @@ impl App {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
// CLI prompt args are plain strings, so they don't provide element ranges.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -1441,6 +1447,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// New sessions start without prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -1490,6 +1498,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// Fork/resume bootstraps here don't carry any prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -1558,6 +1568,8 @@ impl App {
|
||||
app_event_tx: self.app_event_tx.clone(),
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
// Fork/resume bootstraps here don't carry any prefilled message content.
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: self.enhanced_keys_supported,
|
||||
auth_manager: self.auth_manager.clone(),
|
||||
models_manager: self.server.get_models_manager(),
|
||||
@@ -2625,6 +2637,8 @@ mod tests {
|
||||
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
message: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
||||
|
||||
@@ -126,7 +126,9 @@ impl App {
|
||||
self.chat_widget.submit_op(Op::ThreadRollback { num_turns });
|
||||
self.trim_transcript_for_backtrack(selection.nth_user_message);
|
||||
if !selection.prefill.is_empty() {
|
||||
self.chat_widget.set_composer_text(selection.prefill);
|
||||
// TODO: Thread prefill text elements and local images through backtrack.
|
||||
self.chat_widget
|
||||
.set_composer_text(selection.prefill, Vec::new(), Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +436,8 @@ mod tests {
|
||||
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first user".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -450,6 +454,8 @@ mod tests {
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
@@ -478,11 +484,15 @@ mod tests {
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "first".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
Arc::new(UserHistoryCell {
|
||||
message: "second".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>,
|
||||
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
|
||||
as Arc<dyn HistoryCell>,
|
||||
|
||||
@@ -109,9 +109,12 @@ use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
@@ -141,8 +144,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
/// Result returned when the user interacts with the text area.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Queued(String),
|
||||
Submitted {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Queued {
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
Command(SlashCommand),
|
||||
CommandWithArgs(SlashCommand, String),
|
||||
None,
|
||||
@@ -334,7 +343,8 @@ impl ChatComposer {
|
||||
let Some(text) = self.history.on_entry_response(log_id, offset, entry) else {
|
||||
return false;
|
||||
};
|
||||
self.set_text_content(text);
|
||||
// History lookup returns plain text only; no UI element ranges or attachments to restore.
|
||||
self.set_text_content(text, Vec::new(), Vec::new());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -432,12 +442,31 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Replace the entire composer content with `text` and reset cursor.
|
||||
pub(crate) fn set_text_content(&mut self, text: String) {
|
||||
pub(crate) fn set_text_content(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
// Clear any existing content, placeholders, and attachments first.
|
||||
self.textarea.set_text("");
|
||||
self.pending_pastes.clear();
|
||||
self.attached_images.clear();
|
||||
self.textarea.set_text(&text);
|
||||
|
||||
self.textarea.set_text_with_elements(&text, &text_elements);
|
||||
|
||||
let image_placeholders: HashSet<String> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| elem.placeholder.clone())
|
||||
.collect();
|
||||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||||
let placeholder = local_image_label_text(idx + 1);
|
||||
if image_placeholders.contains(&placeholder) {
|
||||
self.attached_images
|
||||
.push(AttachedImage { placeholder, path });
|
||||
}
|
||||
}
|
||||
|
||||
self.textarea.set_cursor(0);
|
||||
self.sync_popups();
|
||||
}
|
||||
@@ -447,7 +476,7 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
let previous = self.current_text();
|
||||
self.set_text_content(String::new());
|
||||
self.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
self.history.reset_navigation();
|
||||
self.history.record_local_submission(&previous);
|
||||
Some(previous)
|
||||
@@ -458,6 +487,28 @@ impl ChatComposer {
|
||||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn text_elements(&self) -> Vec<TextElement> {
|
||||
self.textarea.text_elements()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn local_image_paths(&self) -> Vec<PathBuf> {
|
||||
self.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn local_images(&self) -> Vec<LocalImageAttachment> {
|
||||
self.attached_images
|
||||
.iter()
|
||||
.map(|img| LocalImageAttachment {
|
||||
placeholder: img.placeholder.clone(),
|
||||
path: img.path.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert an attachment placeholder and track it for the next submission.
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
@@ -469,11 +520,23 @@ impl ChatComposer {
|
||||
.push(AttachedImage { placeholder, path });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
let images = std::mem::take(&mut self.attached_images);
|
||||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec<LocalImageAttachment> {
|
||||
let images = std::mem::take(&mut self.attached_images);
|
||||
images
|
||||
.into_iter()
|
||||
.map(|img| LocalImageAttachment {
|
||||
placeholder: img.placeholder,
|
||||
path: img.path,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Flushes any due paste-burst state.
|
||||
///
|
||||
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
|
||||
@@ -705,7 +768,14 @@ impl ChatComposer {
|
||||
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||
{
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(expanded), true);
|
||||
return (
|
||||
InputResult::Submitted {
|
||||
text: expanded,
|
||||
// Expanded prompt is plain text; no UI element ranges to preserve.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
@@ -723,7 +793,14 @@ impl ChatComposer {
|
||||
) {
|
||||
PromptSelectionAction::Submit { text } => {
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(text), true);
|
||||
return (
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
// Prompt submission has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
PromptSelectionAction::Insert { text, cursor } => {
|
||||
let target = cursor.unwrap_or(text.len());
|
||||
@@ -1031,6 +1108,42 @@ impl ChatComposer {
|
||||
lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")
|
||||
}
|
||||
|
||||
fn trim_text_elements(
|
||||
original: &str,
|
||||
trimmed: &str,
|
||||
elements: Vec<TextElement>,
|
||||
) -> Vec<TextElement> {
|
||||
if trimmed.is_empty() || elements.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let trimmed_start = original.len().saturating_sub(original.trim_start().len());
|
||||
let trimmed_end = trimmed_start.saturating_add(trimmed.len());
|
||||
|
||||
elements
|
||||
.into_iter()
|
||||
.filter_map(|elem| {
|
||||
let start = elem.byte_range.start;
|
||||
let end = elem.byte_range.end;
|
||||
if end <= trimmed_start || start >= trimmed_end {
|
||||
return None;
|
||||
}
|
||||
let new_start = start.saturating_sub(trimmed_start);
|
||||
let new_end = end.saturating_sub(trimmed_start).min(trimmed.len());
|
||||
if new_start >= new_end {
|
||||
return None;
|
||||
}
|
||||
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
|
||||
Some(TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn skills_enabled(&self) -> bool {
|
||||
self.skills.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
@@ -1246,7 +1359,7 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
|
||||
fn prepare_submission_text(&mut self) -> Option<String> {
|
||||
fn prepare_submission_text(&mut self) -> Option<(String, Vec<TextElement>)> {
|
||||
// If we have pending placeholder pastes, replace them in the textarea text
|
||||
// and continue to the normal submission flow to handle slash commands.
|
||||
if !self.pending_pastes.is_empty() {
|
||||
@@ -1262,6 +1375,13 @@ impl ChatComposer {
|
||||
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut text_elements = original_text_elements.clone();
|
||||
let input_starts_with_space = original_input.starts_with(' ');
|
||||
self.textarea.set_text("");
|
||||
|
||||
@@ -1276,6 +1396,7 @@ impl ChatComposer {
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
text_elements = Self::trim_text_elements(&original_input, &text, text_elements);
|
||||
|
||||
if let Some((name, _rest)) = parse_slash_name(&text) {
|
||||
let treat_as_plain_text = input_starts_with_space || name.contains('/');
|
||||
@@ -1302,8 +1423,13 @@ impl ChatComposer {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_info_event(message, None),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1315,13 +1441,20 @@ impl ChatComposer {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
// Expanded prompt (e.g. custom prompt) is plain text; no UI element ranges to preserve.
|
||||
text_elements = Vec::new();
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return None;
|
||||
@@ -1329,7 +1462,7 @@ impl ChatComposer {
|
||||
if !text.is_empty() {
|
||||
self.history.record_local_submission(&text);
|
||||
}
|
||||
Some(text)
|
||||
Some((text, text_elements))
|
||||
}
|
||||
|
||||
/// Common logic for handling message submission/queuing.
|
||||
@@ -1378,20 +1511,44 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
let original_input = self.textarea.text().to_string();
|
||||
let original_text_elements = self.textarea.text_elements();
|
||||
let original_local_image_paths = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(result) = self.try_dispatch_slash_command_with_args() {
|
||||
return (result, true);
|
||||
}
|
||||
|
||||
if let Some(text) = self.prepare_submission_text() {
|
||||
if let Some((text, text_elements)) = self.prepare_submission_text() {
|
||||
if should_queue {
|
||||
(InputResult::Queued(text), true)
|
||||
(
|
||||
InputResult::Queued {
|
||||
text,
|
||||
text_elements,
|
||||
},
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
// Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images().
|
||||
(InputResult::Submitted(text), true)
|
||||
(
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Restore text if submission was suppressed
|
||||
self.textarea.set_text(&original_input);
|
||||
// Restore text if submission was suppressed.
|
||||
self.set_text_content(
|
||||
original_input.clone(),
|
||||
original_text_elements,
|
||||
original_local_image_paths,
|
||||
);
|
||||
let cursor = original_input.len().min(self.textarea.text().len());
|
||||
self.textarea.set_cursor(cursor);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
}
|
||||
@@ -1488,7 +1645,7 @@ impl ChatComposer {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(text) = replace_text {
|
||||
self.set_text_content(text);
|
||||
self.set_text_content(text, Vec::new(), Vec::new());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
@@ -1640,15 +1797,6 @@ impl ChatComposer {
|
||||
self.handle_paste(pasted);
|
||||
}
|
||||
|
||||
// Backspace at the start of an image placeholder should delete that placeholder (rather
|
||||
// than deleting content before it). Do this without scanning the full text by consulting
|
||||
// the textarea's element list.
|
||||
if matches!(input.code, KeyCode::Backspace)
|
||||
&& self.try_remove_image_element_at_cursor_start()
|
||||
{
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
|
||||
// Track element removals so we can drop any corresponding placeholders without scanning
|
||||
// the full text. (Placeholders are atomic elements; when deleted, the element disappears.)
|
||||
let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() {
|
||||
@@ -1686,29 +1834,6 @@ impl ChatComposer {
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
fn try_remove_image_element_at_cursor_start(&mut self) -> bool {
|
||||
if self.attached_images.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let p = self.textarea.cursor();
|
||||
let Some(payload) = self.textarea.element_payload_starting_at(p) else {
|
||||
return false;
|
||||
};
|
||||
let Some(idx) = self
|
||||
.attached_images
|
||||
.iter()
|
||||
.position(|img| img.placeholder == payload)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.textarea.replace_range(p..p + payload.len(), "");
|
||||
self.attached_images.remove(idx);
|
||||
self.relabel_attached_images_and_update_placeholders();
|
||||
true
|
||||
}
|
||||
|
||||
fn reconcile_deleted_elements(&mut self, elements_before: Vec<String>) {
|
||||
let elements_after: HashSet<String> =
|
||||
self.textarea.element_payloads().into_iter().collect();
|
||||
@@ -1736,6 +1861,8 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
fn relabel_attached_images_and_update_placeholders(&mut self) {
|
||||
// Renumber by insertion order (attachment list order), and update any matching elements
|
||||
// regardless of where they appear in the text.
|
||||
for idx in 0..self.attached_images.len() {
|
||||
let expected = local_image_label_text(idx + 1);
|
||||
let current = self.attached_images[idx].placeholder.clone();
|
||||
@@ -2199,7 +2326,7 @@ impl Renderable for ChatComposer {
|
||||
.unwrap_or("Input disabled.")
|
||||
.to_string()
|
||||
};
|
||||
let placeholder = Span::from(text).dim();
|
||||
let placeholder = Span::from(text).dim().italic();
|
||||
Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
|
||||
}
|
||||
}
|
||||
@@ -2449,7 +2576,7 @@ mod tests {
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
composer.set_text_content("draft text".to_string());
|
||||
composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new());
|
||||
assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string()));
|
||||
assert!(composer.is_empty());
|
||||
|
||||
@@ -2742,7 +2869,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "1あ"),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
@@ -2882,6 +3009,7 @@ mod tests {
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
composer.set_steer_enabled(true);
|
||||
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE));
|
||||
|
||||
@@ -2913,7 +3041,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "あ"),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, "あ"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
@@ -3117,7 +3245,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "hello"),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, "hello"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
@@ -3181,7 +3309,7 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, large),
|
||||
InputResult::Submitted { text, .. } => assert_eq!(text, large),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
@@ -3443,10 +3571,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/init'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/init'"),
|
||||
@@ -3522,10 +3650,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/diff'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch after Tab completion, got literal queue")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/diff'"),
|
||||
@@ -3561,10 +3689,10 @@ mod tests {
|
||||
InputResult::CommandWithArgs(_, _) => {
|
||||
panic!("expected command dispatch without args for '/mention'")
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
InputResult::Queued(_) => {
|
||||
InputResult::Queued { .. } => {
|
||||
panic!("expected command dispatch, but composer queued literal text")
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/mention'"),
|
||||
@@ -3649,7 +3777,7 @@ mod tests {
|
||||
// Submit and verify final expansion
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -3862,7 +3990,7 @@ mod tests {
|
||||
|
||||
// --- Image attachment tests ---
|
||||
#[test]
|
||||
fn attach_image_and_submit_includes_image_paths() {
|
||||
fn attach_image_and_submit_includes_local_image_paths() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
@@ -3879,7 +4007,21 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"),
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1] hi");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: "[Image #1]".len()
|
||||
}
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3903,7 +4045,21 @@ mod tests {
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"),
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1]");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
start: 0,
|
||||
end: "[Image #1]".len()
|
||||
}
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
let imgs = composer.take_recent_submission_images();
|
||||
@@ -3927,6 +4083,24 @@ mod tests {
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 0: backspace at start should remove the preceding character, not the placeholder.
|
||||
composer.set_text_content("A".to_string(), Vec::new(), Vec::new());
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder0 = composer.attached_images[0].placeholder.clone();
|
||||
let start0 = composer
|
||||
.textarea
|
||||
.text()
|
||||
.find(&placeholder0)
|
||||
.expect("placeholder present");
|
||||
composer.textarea.set_cursor(start0);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert_eq!(composer.textarea.text(), placeholder0);
|
||||
assert_eq!(composer.attached_images.len(), 1);
|
||||
|
||||
composer.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
composer.attach_image(path.clone());
|
||||
let placeholder = composer.attached_images[0].placeholder.clone();
|
||||
|
||||
// Case 1: backspace at end
|
||||
composer.textarea.move_cursor_to_end_of_line(false);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
@@ -4030,6 +4204,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_reordered_image_one_renumbers_text_in_place() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
let path1 = PathBuf::from("/tmp/image_first.png");
|
||||
let path2 = PathBuf::from("/tmp/image_second.png");
|
||||
let placeholder1 = local_image_label_text(1);
|
||||
let placeholder2 = local_image_label_text(2);
|
||||
|
||||
// Placeholders can be reordered in the text buffer; deleting image #1 should renumber
|
||||
// image #2 wherever it appears, not just after the cursor.
|
||||
let text = format!("Test {placeholder2} test {placeholder1}");
|
||||
let start2 = text.find(&placeholder2).expect("placeholder2 present");
|
||||
let start1 = text.find(&placeholder1).expect("placeholder1 present");
|
||||
let text_elements = vec![
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: start2,
|
||||
end: start2 + placeholder2.len(),
|
||||
},
|
||||
placeholder: Some(placeholder2),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: start1,
|
||||
end: start1 + placeholder1.len(),
|
||||
},
|
||||
placeholder: Some(placeholder1.clone()),
|
||||
},
|
||||
];
|
||||
composer.set_text_content(text, text_elements, vec![path1, path2.clone()]);
|
||||
|
||||
let end1 = start1 + placeholder1.len();
|
||||
composer.textarea.set_cursor(end1);
|
||||
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
composer.textarea.text(),
|
||||
format!("Test {placeholder1} test ")
|
||||
);
|
||||
assert_eq!(
|
||||
vec![AttachedImage {
|
||||
path: path2,
|
||||
placeholder: placeholder1
|
||||
}],
|
||||
composer.attached_images,
|
||||
"attachment renumbered after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_first_text_element_renumbers_following_text_element() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -4127,7 +4364,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == prompt_text
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4159,10 +4399,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Review Alice changes on main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Review Alice changes on main"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4194,10 +4435,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Pair Alice Smith with dev-main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Pair Alice Smith with dev-main"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4254,7 +4496,7 @@ mod tests {
|
||||
|
||||
// Verify the custom prompt was expanded with the large content as positional arg
|
||||
match result {
|
||||
InputResult::Submitted(text) => {
|
||||
InputResult::Submitted { text, .. } => {
|
||||
// The prompt should be expanded, with the large content replacing $1
|
||||
assert_eq!(
|
||||
text,
|
||||
@@ -4292,7 +4534,7 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, "/Users/example/project/src/main.rs");
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -4327,7 +4569,7 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
if let InputResult::Submitted(text) = result {
|
||||
if let InputResult::Submitted { text, .. } = result {
|
||||
assert_eq!(text, "/this-looks-like-a-command");
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
@@ -4476,7 +4718,10 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
|
||||
assert_eq!(InputResult::Submitted(expected), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == expected
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4507,7 +4752,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == "Echo: hi"
|
||||
));
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
@@ -4579,10 +4827,11 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Cost: $$ and first: x".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. }
|
||||
if text == "Cost: $$ and first: x"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4619,7 +4868,10 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let expected = "First: one two\nSecond: one two".to_string();
|
||||
assert_eq!(InputResult::Submitted(expected), result);
|
||||
assert!(matches!(
|
||||
result,
|
||||
InputResult::Submitted { text, .. } if text == expected
|
||||
));
|
||||
}
|
||||
|
||||
/// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If
|
||||
@@ -4744,7 +4996,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Simulate history-like content: "/ test"
|
||||
composer.set_text_content("/ test".to_string());
|
||||
composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
// After set_text_content -> sync_popups is called; popup should NOT be Command.
|
||||
assert!(
|
||||
@@ -4774,21 +5026,21 @@ mod tests {
|
||||
);
|
||||
|
||||
// Case 1: bare "/"
|
||||
composer.set_text_content("/".to_string());
|
||||
composer.set_text_content("/".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"bare '/' should activate slash popup"
|
||||
);
|
||||
|
||||
// Case 2: valid prefix "/re" (matches /review, /resume, etc.)
|
||||
composer.set_text_content("/re".to_string());
|
||||
composer.set_text_content("/re".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"'/re' should activate slash popup via prefix match"
|
||||
);
|
||||
|
||||
// Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback)
|
||||
composer.set_text_content("/ac".to_string());
|
||||
composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::Command(_)),
|
||||
"'/ac' should activate slash popup via fuzzy match"
|
||||
@@ -4797,7 +5049,7 @@ mod tests {
|
||||
// Case 4: invalid prefix "/zzz" – still allowed to open popup if it
|
||||
// matches no built-in command; our current logic will not open popup.
|
||||
// Verify that explicitly.
|
||||
composer.set_text_content("/zzz".to_string());
|
||||
composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new());
|
||||
assert!(
|
||||
matches!(composer.active_popup, ActivePopup::None),
|
||||
"'/zzz' should not activate slash popup because it is not a prefix of any built-in command"
|
||||
@@ -4820,7 +5072,7 @@ mod tests {
|
||||
false,
|
||||
);
|
||||
|
||||
composer.set_text_content("hello".to_string());
|
||||
composer.set_text_content("hello".to_string(), Vec::new(), Vec::new());
|
||||
composer.set_input_enabled(false, Some("Input disabled for test.".to_string()));
|
||||
|
||||
let (result, needs_redraw) =
|
||||
|
||||
@@ -27,6 +27,7 @@ use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::features::Features;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -37,6 +38,12 @@ mod approval_overlay;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
mod bottom_pane_view;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct LocalImageAttachment {
|
||||
pub(crate) placeholder: String,
|
||||
pub(crate) path: PathBuf,
|
||||
}
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
@@ -301,8 +308,14 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Replace the composer text with `text`.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.composer.set_text_content(text);
|
||||
pub(crate) fn set_composer_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
self.composer
|
||||
.set_text_content(text, text_elements, local_image_paths);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -326,6 +339,19 @@ impl BottomPane {
|
||||
self.composer.current_text()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> {
|
||||
self.composer.text_elements()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_local_images(&self) -> Vec<LocalImageAttachment> {
|
||||
self.composer.local_images()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn composer_local_image_paths(&self) -> Vec<PathBuf> {
|
||||
self.composer.local_image_paths()
|
||||
}
|
||||
|
||||
/// Update the status indicator header (defaults to "Working") and details below it.
|
||||
///
|
||||
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
|
||||
@@ -619,10 +645,18 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> {
|
||||
self.composer.take_recent_submission_images()
|
||||
}
|
||||
|
||||
pub(crate) fn take_recent_submission_images_with_placeholders(
|
||||
&mut self,
|
||||
) -> Vec<LocalImageAttachment> {
|
||||
self.composer
|
||||
.take_recent_submission_images_with_placeholders()
|
||||
}
|
||||
|
||||
fn as_renderable(&'_ self) -> RenderableItem<'_> {
|
||||
if let Some(view) = self.active_view() {
|
||||
RenderableItem::Borrowed(view)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::key_hint::is_altgr;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement as UserTextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -60,10 +62,33 @@ impl TextArea {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the textarea text and clear any existing text elements.
|
||||
pub fn set_text(&mut self, text: &str) {
|
||||
self.set_text_inner(text, None);
|
||||
}
|
||||
|
||||
/// Replace the textarea text and set the provided text elements.
|
||||
pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) {
|
||||
self.set_text_inner(text, Some(elements));
|
||||
}
|
||||
|
||||
fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) {
|
||||
self.text = text.to_string();
|
||||
self.cursor_pos = self.cursor_pos.clamp(0, self.text.len());
|
||||
self.elements.clear();
|
||||
if let Some(elements) = elements {
|
||||
for elem in elements {
|
||||
let mut start = elem.byte_range.start.min(self.text.len());
|
||||
let mut end = elem.byte_range.end.min(self.text.len());
|
||||
start = self.clamp_pos_to_char_boundary(start);
|
||||
end = self.clamp_pos_to_char_boundary(end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
self.elements.push(TextElement { range: start..end });
|
||||
}
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos);
|
||||
self.wrap_cache.replace(None);
|
||||
self.preferred_col = None;
|
||||
@@ -722,6 +747,22 @@ impl TextArea {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn text_elements(&self) -> Vec<UserTextElement> {
|
||||
self.elements
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let placeholder = self.text.get(e.range.clone()).map(str::to_string);
|
||||
UserTextElement {
|
||||
byte_range: ByteRange {
|
||||
start: e.range.start,
|
||||
end: e.range.end,
|
||||
},
|
||||
placeholder,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn element_payload_starting_at(&self, pos: usize) -> Option<String> {
|
||||
let pos = pos.min(self.text.len());
|
||||
let elem = self.elements.iter().find(|e| e.range.start == pos)?;
|
||||
|
||||
@@ -90,7 +90,9 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -122,6 +124,7 @@ use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
@@ -300,6 +303,7 @@ pub(crate) struct ChatWidgetInit {
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) initial_prompt: Option<String>,
|
||||
pub(crate) initial_images: Vec<PathBuf>,
|
||||
pub(crate) initial_text_elements: Vec<TextElement>,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) models_manager: Arc<ModelsManager>,
|
||||
@@ -450,14 +454,17 @@ pub(crate) struct ActiveCellTranscriptKey {
|
||||
|
||||
struct UserMessage {
|
||||
text: String,
|
||||
image_paths: Vec<PathBuf>,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
text_elements: Vec<TextElement>,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
image_paths: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,19 +473,104 @@ impl From<&str> for UserMessage {
|
||||
fn from(text: &str) -> Self {
|
||||
Self {
|
||||
text: text.to_string(),
|
||||
image_paths: Vec::new(),
|
||||
local_images: Vec::new(),
|
||||
// Plain text conversion has no UI element ranges.
|
||||
text_elements: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
fn create_initial_user_message(
|
||||
text: String,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> Option<UserMessage> {
|
||||
if text.is_empty() && local_image_paths.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(UserMessage { text, image_paths })
|
||||
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,
|
||||
text_elements,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_placeholders_for_message(
|
||||
text: &str,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_images: Vec<LocalImageAttachment>,
|
||||
next_label: &mut usize,
|
||||
) -> (String, Vec<TextElement>, Vec<LocalImageAttachment>) {
|
||||
if local_images.is_empty() {
|
||||
return (text.to_string(), text_elements, Vec::new());
|
||||
}
|
||||
|
||||
// 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]).
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 replacement = elem
|
||||
.placeholder
|
||||
.as_ref()
|
||||
.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(placeholder) = elem.placeholder.as_ref()
|
||||
&& let Some(remapped) = mapping.get(placeholder)
|
||||
{
|
||||
elem.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, remapped_images)
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
|
||||
///
|
||||
@@ -727,17 +819,16 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(mut snapshot) = snapshot {
|
||||
if snapshot.credits.is_none() {
|
||||
snapshot.credits = self
|
||||
.rate_limit_snapshot
|
||||
snapshot.credits = snapshot.credits.or_else(|| {
|
||||
self.rate_limit_snapshot
|
||||
.as_ref()
|
||||
.and_then(|display| display.credits.as_ref())
|
||||
.map(|credits| CreditsSnapshot {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance.clone(),
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
self.plan_type = snapshot.plan_type.or(self.plan_type);
|
||||
|
||||
@@ -905,22 +996,65 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
// If any messages were queued during the task, restore them into the composer.
|
||||
// Subtlety: each queued draft numbers its attachments from [Image #1], so when we
|
||||
// concatenate multiple drafts we must reassign placeholders in a stable order so the
|
||||
// merged attachment list matches the labels in the combined text.
|
||||
if !self.queued_user_messages.is_empty() {
|
||||
let queued_text = self
|
||||
let existing_text = self.bottom_pane.composer_text();
|
||||
let existing_text_elements = self.bottom_pane.composer_text_elements();
|
||||
let existing_local_images = self.bottom_pane.composer_local_images();
|
||||
let mut combined_text = String::new();
|
||||
let mut combined_text_elements = Vec::new();
|
||||
let mut combined_local_images = Vec::new();
|
||||
let mut combined_offset = 0usize;
|
||||
let mut next_image_label = 1usize;
|
||||
|
||||
let mut to_merge: Vec<(String, Vec<TextElement>, Vec<LocalImageAttachment>)> = self
|
||||
.queued_user_messages
|
||||
.iter()
|
||||
.map(|m| m.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let existing_text = self.bottom_pane.composer_text();
|
||||
let combined = if existing_text.is_empty() {
|
||||
queued_text
|
||||
} else if queued_text.is_empty() {
|
||||
existing_text
|
||||
} else {
|
||||
format!("{queued_text}\n{existing_text}")
|
||||
};
|
||||
self.bottom_pane.set_composer_text(combined);
|
||||
.map(|message| {
|
||||
(
|
||||
message.text.clone(),
|
||||
message.text_elements.clone(),
|
||||
message.local_images.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !existing_text.is_empty() || !existing_local_images.is_empty() {
|
||||
to_merge.push((existing_text, existing_text_elements, existing_local_images));
|
||||
}
|
||||
|
||||
for (idx, (text, elements, local_images)) in to_merge.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
combined_text.push('\n');
|
||||
combined_offset += 1;
|
||||
}
|
||||
let (text, elements, local_images) = remap_placeholders_for_message(
|
||||
&text,
|
||||
elements,
|
||||
local_images,
|
||||
&mut next_image_label,
|
||||
);
|
||||
let base = combined_offset;
|
||||
combined_text.push_str(&text);
|
||||
combined_offset += text.len();
|
||||
combined_text_elements.extend(elements.into_iter().map(|mut elem| {
|
||||
elem.byte_range.start += base;
|
||||
elem.byte_range.end += base;
|
||||
elem
|
||||
}));
|
||||
combined_local_images.extend(local_images);
|
||||
}
|
||||
|
||||
let combined_local_image_paths = combined_local_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_composer_text(
|
||||
combined_text,
|
||||
combined_text_elements,
|
||||
combined_local_image_paths,
|
||||
);
|
||||
// Clear the queue and update the status indicator list.
|
||||
self.queued_user_messages.clear();
|
||||
self.refresh_queued_user_messages();
|
||||
@@ -1434,6 +1568,7 @@ impl ChatWidget {
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -1482,6 +1617,7 @@ impl ChatWidget {
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
),
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -1537,6 +1673,7 @@ impl ChatWidget {
|
||||
app_event_tx,
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
enhanced_keys_supported,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
@@ -1578,6 +1715,7 @@ impl ChatWidget {
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
initial_images,
|
||||
initial_text_elements,
|
||||
),
|
||||
token_info: None,
|
||||
rate_limit_snapshot: None,
|
||||
@@ -1689,46 +1827,64 @@ impl ChatWidget {
|
||||
} if !self.queued_user_messages.is_empty() => {
|
||||
// Prefer the most recently queued item.
|
||||
if let Some(user_message) = self.queued_user_messages.pop_back() {
|
||||
self.bottom_pane.set_composer_text(user_message.text);
|
||||
let local_image_paths = user_message
|
||||
.local_images
|
||||
.iter()
|
||||
.map(|img| img.path.clone())
|
||||
.collect();
|
||||
self.bottom_pane.set_composer_text(
|
||||
user_message.text,
|
||||
user_message.text_elements,
|
||||
local_image_paths,
|
||||
);
|
||||
self.refresh_queued_user_messages();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// Enter always sends messages immediately (bypasses queue check)
|
||||
// Clear any reasoning status header when submitting a new message
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
if !self.is_session_configured() {
|
||||
self.queue_user_message(user_message);
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
}
|
||||
InputResult::Queued(text) => {
|
||||
// Tab queues the message if a task is running, otherwise submits immediately
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
};
|
||||
_ => match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
// Enter always sends messages immediately (bypasses queue check)
|
||||
// Clear any reasoning status header when submitting a new message
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements,
|
||||
};
|
||||
if !self.is_session_configured() {
|
||||
self.queue_user_message(user_message);
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
}
|
||||
InputResult::Queued {
|
||||
text,
|
||||
text_elements,
|
||||
} => {
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
text_elements,
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
InputResult::CommandWithArgs(cmd, args) => {
|
||||
self.dispatch_command_with_args(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2018,8 +2174,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
let UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
text_elements,
|
||||
} = user_message;
|
||||
if text.is_empty() && local_images.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2043,15 +2203,16 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
for path in image_paths {
|
||||
items.push(UserInput::LocalImage { path });
|
||||
for image in &local_images {
|
||||
items.push(UserInput::LocalImage {
|
||||
path: image.path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
// TODO: Thread text element ranges from the composer input. Empty keeps old behavior.
|
||||
items.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: Vec::new(),
|
||||
text_elements: text_elements.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2085,7 +2246,12 @@ impl ChatWidget {
|
||||
|
||||
// Only show the text portion in conversation history.
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text));
|
||||
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
text,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
));
|
||||
}
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
@@ -2295,13 +2461,13 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||||
let message = event.message.trim();
|
||||
// Only show the text portion in conversation history.
|
||||
if !message.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(message.to_string()));
|
||||
if !event.message.trim().is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(
|
||||
event.message,
|
||||
event.text_elements,
|
||||
event.local_images,
|
||||
));
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
/// Exit the UI immediately without waiting for shutdown.
|
||||
@@ -3806,8 +3972,14 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
/// Replace the composer content with the provided text and reset cursor.
|
||||
pub(crate) fn set_composer_text(&mut self, text: String) {
|
||||
self.bottom_pane.set_composer_text(text);
|
||||
pub(crate) fn set_composer_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) {
|
||||
self.bottom_pane
|
||||
.set_composer_text(text, text_elements, local_image_paths);
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
|
||||
@@ -8,6 +8,8 @@ use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::LocalImageAttachment;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
use assert_matches::assert_matches;
|
||||
@@ -64,6 +66,8 @@ use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -169,6 +173,298 @@ async fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let message = format!("{placeholder} replayed");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let local_images = vec![PathBuf::from("/tmp/replay.png")];
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
|
||||
message: message.clone(),
|
||||
images: None,
|
||||
text_elements: text_elements.clone(),
|
||||
local_images: local_images.clone(),
|
||||
})]),
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((
|
||||
cell.message.clone(),
|
||||
cell.text_elements.clone(),
|
||||
cell.local_image_paths.clone(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_message, stored_elements, stored_images) =
|
||||
user_cell.expect("expected a replayed user history cell");
|
||||
assert_eq!(stored_message, message);
|
||||
assert_eq!(stored_elements, text_elements);
|
||||
assert_eq!(stored_images, local_images);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_preserves_text_elements_and_local_images() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: rollout_file.path().to_path_buf(),
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "initial".into(),
|
||||
msg: EventMsg::SessionConfigured(configured),
|
||||
});
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let text = format!("{placeholder} submit");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let local_images = vec![PathBuf::from("/tmp/submitted.png")];
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text(text.clone(), text_elements.clone(), local_images.clone());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
let mut user_input_items = None;
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if let Op::UserInput { items, .. } = op {
|
||||
user_input_items = Some(items);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let items = user_input_items.expect("expected Op::UserInput");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(
|
||||
items[0],
|
||||
UserInput::LocalImage {
|
||||
path: local_images[0].clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[1],
|
||||
UserInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: text_elements.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
let mut user_cell = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = ev
|
||||
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
||||
{
|
||||
user_cell = Some((
|
||||
cell.message.clone(),
|
||||
cell.text_elements.clone(),
|
||||
cell.local_image_paths.clone(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let (stored_message, stored_elements, stored_images) =
|
||||
user_cell.expect("expected submitted user history cell");
|
||||
assert_eq!(stored_message, text);
|
||||
assert_eq!(stored_elements, text_elements);
|
||||
assert_eq!(stored_images, local_images);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
let first_placeholder = "[Image #1]";
|
||||
let first_text = format!("{first_placeholder} first");
|
||||
let first_elements = vec![TextElement {
|
||||
byte_range: (0..first_placeholder.len()).into(),
|
||||
placeholder: Some(first_placeholder.to_string()),
|
||||
}];
|
||||
let first_images = [PathBuf::from("/tmp/first.png")];
|
||||
|
||||
let second_placeholder = "[Image #1]";
|
||||
let second_text = format!("{second_placeholder} second");
|
||||
let second_elements = vec![TextElement {
|
||||
byte_range: (0..second_placeholder.len()).into(),
|
||||
placeholder: Some(second_placeholder.to_string()),
|
||||
}];
|
||||
let second_images = [PathBuf::from("/tmp/second.png")];
|
||||
|
||||
let existing_placeholder = "[Image #1]";
|
||||
let existing_text = format!("{existing_placeholder} existing");
|
||||
let existing_elements = vec![TextElement {
|
||||
byte_range: (0..existing_placeholder.len()).into(),
|
||||
placeholder: Some(existing_placeholder.to_string()),
|
||||
}];
|
||||
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
|
||||
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
text: first_text,
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: first_placeholder.to_string(),
|
||||
path: first_images[0].clone(),
|
||||
}],
|
||||
text_elements: first_elements,
|
||||
});
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
text: second_text,
|
||||
local_images: vec![LocalImageAttachment {
|
||||
placeholder: second_placeholder.to_string(),
|
||||
path: second_images[0].clone(),
|
||||
}],
|
||||
text_elements: second_elements,
|
||||
});
|
||||
chat.refresh_queued_user_messages();
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text(existing_text, existing_elements, existing_images.clone());
|
||||
|
||||
// When interrupted, queued messages are merged into the composer; image placeholders
|
||||
// must be renumbered to match the combined local image list.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "interrupt".into(),
|
||||
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}),
|
||||
});
|
||||
|
||||
let first = "[Image #1] first".to_string();
|
||||
let second = "[Image #2] second".to_string();
|
||||
let third = "[Image #3] existing".to_string();
|
||||
let expected_text = format!("{first}\n{second}\n{third}");
|
||||
assert_eq!(chat.bottom_pane.composer_text(), expected_text);
|
||||
|
||||
let first_start = 0;
|
||||
let second_start = first.len() + 1;
|
||||
let third_start = second_start + second.len() + 1;
|
||||
let expected_elements = vec![
|
||||
TextElement {
|
||||
byte_range: (first_start..first_start + "[Image #1]".len()).into(),
|
||||
placeholder: Some("[Image #1]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (second_start..second_start + "[Image #2]".len()).into(),
|
||||
placeholder: Some("[Image #2]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (third_start..third_start + "[Image #3]".len()).into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
];
|
||||
assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_local_image_paths(),
|
||||
vec![
|
||||
first_images[0].clone(),
|
||||
second_images[0].clone(),
|
||||
existing_images[0].clone(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remap_placeholders_uses_attachment_labels() {
|
||||
let placeholder_one = "[Image #1]";
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: Some(placeholder_two.to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: Some(placeholder_one.to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
LocalImageAttachment {
|
||||
placeholder: placeholder_one.to_string(),
|
||||
path: PathBuf::from("/tmp/one.png"),
|
||||
},
|
||||
LocalImageAttachment {
|
||||
placeholder: placeholder_two.to_string(),
|
||||
path: PathBuf::from("/tmp/two.png"),
|
||||
},
|
||||
];
|
||||
let mut next_label = 3usize;
|
||||
let (remapped, remapped_elements, remapped_images) =
|
||||
remap_placeholders_for_message(&text, elements, attachments, &mut next_label);
|
||||
|
||||
assert_eq!(remapped, "[Image #4] before [Image #3]");
|
||||
assert_eq!(
|
||||
remapped_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remapped_images,
|
||||
vec![
|
||||
LocalImageAttachment {
|
||||
placeholder: "[Image #3]".to_string(),
|
||||
path: PathBuf::from("/tmp/one.png"),
|
||||
},
|
||||
LocalImageAttachment {
|
||||
placeholder: "[Image #4]".to_string(),
|
||||
path: PathBuf::from("/tmp/two.png"),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Entering review mode uses the hint provided by the review request.
|
||||
#[tokio::test]
|
||||
async fn entered_review_mode_uses_request_hint() {
|
||||
@@ -340,6 +636,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
app_event_tx: tx,
|
||||
initial_prompt: None,
|
||||
initial_images: Vec::new(),
|
||||
initial_text_elements: Vec::new(),
|
||||
enhanced_keys_supported: false,
|
||||
auth_manager,
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
@@ -1013,7 +1310,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
assert!(!chat.bottom_pane.is_task_running());
|
||||
|
||||
// Submit an initial prompt to seed history.
|
||||
chat.bottom_pane.set_composer_text("repeat me".to_string());
|
||||
chat.bottom_pane
|
||||
.set_composer_text("repeat me".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Simulate an active task so further submissions are queued.
|
||||
@@ -1049,7 +1347,7 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
||||
assert!(chat.bottom_pane.status_widget().is_none());
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("queued submission".to_string());
|
||||
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
@@ -2374,7 +2672,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
|
||||
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("current draft".to_string());
|
||||
.set_composer_text("current draft".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
chat.queued_user_messages
|
||||
.push_back(UserMessage::from("first queued".to_string()));
|
||||
@@ -3356,8 +3654,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
delta: "**Investigating rendering code**".into(),
|
||||
}),
|
||||
});
|
||||
chat.bottom_pane
|
||||
.set_composer_text("Summarize recent commits".to_string());
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"Summarize recent commits".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
let width: u16 = 80;
|
||||
let ui_height: u16 = chat.desired_height(width);
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
@@ -51,6 +52,7 @@ use mcp_types::Resource;
|
||||
use mcp_types::ResourceLink;
|
||||
use mcp_types::ResourceTemplate;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
@@ -214,6 +216,8 @@ impl dyn HistoryCell {
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UserHistoryCell {
|
||||
pub message: String,
|
||||
pub text_elements: Vec<TextElement>,
|
||||
pub local_image_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl HistoryCell for UserHistoryCell {
|
||||
@@ -229,13 +233,75 @@ impl HistoryCell for UserHistoryCell {
|
||||
.max(1);
|
||||
|
||||
let style = user_message_style();
|
||||
let element_style = style.fg(Color::Cyan);
|
||||
|
||||
let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners(
|
||||
self.message.lines().map(|l| Line::from(l).style(style)),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
);
|
||||
let (wrapped, joiner_before) = if self.text_elements.is_empty() {
|
||||
crate::wrapping::word_wrap_lines_with_joiners(
|
||||
self.message.lines().map(|l| Line::from(l).style(style)),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
)
|
||||
} else {
|
||||
let mut elements = self.text_elements.clone();
|
||||
elements.sort_by_key(|e| e.byte_range.start);
|
||||
let mut offset = 0usize;
|
||||
let mut raw_lines: Vec<Line<'static>> = Vec::new();
|
||||
for line_text in self.message.lines() {
|
||||
let line_start = offset;
|
||||
let line_end = line_start + line_text.len();
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
// Track how much of the line we've emitted to interleave plain and styled spans.
|
||||
let mut cursor = line_start;
|
||||
for elem in &elements {
|
||||
let start = elem.byte_range.start.max(line_start);
|
||||
let end = elem.byte_range.end.min(line_end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let rel_start = start - line_start;
|
||||
let rel_end = end - line_start;
|
||||
// Guard against malformed UTF-8 byte ranges from upstream data; skip
|
||||
// invalid elements rather than panicking while rendering history.
|
||||
if !line_text.is_char_boundary(rel_start)
|
||||
|| !line_text.is_char_boundary(rel_end)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rel_cursor = cursor - line_start;
|
||||
if cursor < start
|
||||
&& line_text.is_char_boundary(rel_cursor)
|
||||
&& let Some(segment) = line_text.get(rel_cursor..rel_start)
|
||||
{
|
||||
spans.push(Span::from(segment.to_string()));
|
||||
}
|
||||
if let Some(segment) = line_text.get(rel_start..rel_end) {
|
||||
spans.push(Span::styled(segment.to_string(), element_style));
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
let rel_cursor = cursor - line_start;
|
||||
if cursor < line_end
|
||||
&& line_text.is_char_boundary(rel_cursor)
|
||||
&& let Some(segment) = line_text.get(rel_cursor..)
|
||||
{
|
||||
spans.push(Span::from(segment.to_string()));
|
||||
}
|
||||
let line = if spans.is_empty() {
|
||||
Line::from(line_text.to_string()).style(style)
|
||||
} else {
|
||||
Line::from(spans).style(style)
|
||||
};
|
||||
raw_lines.push(line);
|
||||
// TextArea normalizes newlines to '\n', so advancing by 1 is correct.
|
||||
offset = line_end + 1;
|
||||
}
|
||||
crate::wrapping::word_wrap_lines_with_joiners(
|
||||
raw_lines,
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
)
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut joins: Vec<Option<String>> = Vec::new();
|
||||
@@ -955,8 +1021,16 @@ pub(crate) fn new_session_info(
|
||||
SessionInfoCell(CompositeHistoryCell { parts })
|
||||
}
|
||||
|
||||
pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
|
||||
UserHistoryCell { message }
|
||||
pub(crate) fn new_user_prompt(
|
||||
message: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
) -> UserHistoryCell {
|
||||
UserHistoryCell {
|
||||
message,
|
||||
text_elements,
|
||||
local_image_paths,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -2717,6 +2791,8 @@ mod tests {
|
||||
let msg = "one two three four five six seven";
|
||||
let cell = UserHistoryCell {
|
||||
message: msg.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
};
|
||||
|
||||
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
|
||||
|
||||
@@ -48,13 +48,14 @@ impl ComposerInput {
|
||||
|
||||
/// Clear the input text.
|
||||
pub fn clear(&mut self) {
|
||||
self.inner.set_text_content(String::new());
|
||||
self.inner
|
||||
.set_text_content(String::new(), Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
/// Feed a key event into the composer and return a high-level action.
|
||||
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
|
||||
let action = match self.inner.handle_key_event(key).0 {
|
||||
InputResult::Submitted(text) => ComposerAction::Submitted(text),
|
||||
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
|
||||
_ => ComposerAction::None,
|
||||
};
|
||||
self.drain_app_events();
|
||||
|
||||
@@ -1018,6 +1018,8 @@ mod tests {
|
||||
let mut cache = TranscriptViewCache::new();
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(UserHistoryCell {
|
||||
message: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
})];
|
||||
|
||||
cache.ensure_wrapped(&cells, 20);
|
||||
|
||||
Reference in New Issue
Block a user