Compare commits

...

6 Commits

Author SHA1 Message Date
Charles Cunningham
649a456fb0 Fix test 2026-01-16 10:17:15 -08:00
Charles Cunningham
70424769d1 Reduce diff 2026-01-16 10:17:15 -08:00
Charles Cunningham
a0e778795c Remove diff 2026-01-16 10:17:15 -08:00
Charles Cunningham
ef828ac65a Small tweaks 2026-01-16 10:17:15 -08:00
Charles Cunningham
2821aef611 Remove unnecessary diff 2026-01-16 10:17:15 -08:00
Charles Cunningham
e50f924e4f Wire text elements through TUI and TUI2 2026-01-16 10:17:14 -08:00
19 changed files with 2162 additions and 377 deletions

View File

@@ -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> {

View File

@@ -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>,

View File

@@ -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) =

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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.

View File

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

View File

@@ -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> {

View File

@@ -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>,

View File

@@ -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) =

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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.

View File

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

View File

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