Wire text elements through TUI and TUI2

This commit is contained in:
Charles Cunningham
2026-01-16 10:17:14 -08:00
parent e650d4b02c
commit e50f924e4f
23 changed files with 2214 additions and 464 deletions
+14
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> {
+11 -1
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>,
+318 -58
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,7 +1490,11 @@ 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.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.textarea.set_cursor(original_input.len());
return None;
}
@@ -1382,13 +1507,19 @@ 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.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.textarea.set_cursor(original_input.len());
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 +1527,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 +1576,43 @@ 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,
);
self.textarea.set_cursor(original_input.len());
(InputResult::None, true)
}
}
@@ -1555,7 +1709,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);
}
}
@@ -2230,7 +2384,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 +2641,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 +2948,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 +3270,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 +3336,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 +3609,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 +3688,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 +3727,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 +3815,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 +4028,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 +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] 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 +4083,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();
@@ -4070,6 +4252,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 +4412,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 +4447,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 +4483,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 +4544,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 +4582,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 +4617,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 +4766,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 +4800,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 +4875,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 +4916,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 +5073,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 +5103,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 +5126,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 +5261,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) =
+36 -2
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)
+9 -27
View File
@@ -87,8 +87,12 @@ impl SkillPopup {
.into_iter()
.map(|(idx, indices, _score)| {
let skill = &self.skills[idx];
let name = truncate_text(skill_display_name(skill), 21);
let description = skill_description(skill).to_string();
let name = truncate_text(&skill.name, 21);
let description = skill
.short_description
.as_ref()
.unwrap_or(&skill.description)
.clone();
GenericDisplayRow {
name,
match_indices: indices,
@@ -113,20 +117,15 @@ impl SkillPopup {
}
for (idx, skill) in self.skills.iter().enumerate() {
let display_name = skill_display_name(skill);
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
if let Some((indices, score)) = fuzzy_match(&skill.name, filter) {
out.push((idx, Some(indices), score));
} else if display_name != skill.name
&& let Some((_indices, score)) = fuzzy_match(&skill.name, filter)
{
out.push((idx, None, score));
}
}
out.sort_by(|a, b| {
a.2.cmp(&b.2).then_with(|| {
let an = skill_display_name(&self.skills[a.0]);
let bn = skill_display_name(&self.skills[b.0]);
let an = &self.skills[a.0].name;
let bn = &self.skills[b.0].name;
an.cmp(bn)
})
});
@@ -178,20 +177,3 @@ fn skill_popup_hint_line() -> Line<'static> {
" to close".into(),
])
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
fn skill_description(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description)
}
+41
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)?;
+294 -115
View File
@@ -87,12 +87,13 @@ use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WarningEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_core::skills::model::SkillInterface;
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 +129,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;
@@ -135,7 +137,6 @@ use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::collab;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
@@ -347,6 +348,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 +510,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 +529,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.
///
@@ -755,7 +845,6 @@ impl ChatWidget {
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.unified_exec_wait_streak = None;
self.clear_unified_exec_processes();
self.request_redraw();
// If there is a queued user message, send exactly one now to begin the next turn.
@@ -892,7 +981,6 @@ impl ChatWidget {
self.running_commands.clear();
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.clear_unified_exec_processes();
self.stream_controller = None;
self.maybe_show_pending_rate_limit_prompt();
}
@@ -985,6 +1073,8 @@ impl ChatWidget {
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn();
self.unified_exec_processes.clear();
self.sync_unified_exec_footer();
if reason != TurnAbortReason::ReviewEnded {
self.add_to_history(history_cell::new_error_event(
@@ -993,22 +1083,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();
@@ -1205,14 +1338,6 @@ impl ChatWidget {
self.bottom_pane.set_unified_exec_processes(processes);
}
fn clear_unified_exec_processes(&mut self) {
if self.unified_exec_processes.is_empty() {
return;
}
self.unified_exec_processes.clear();
self.sync_unified_exec_footer();
}
fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) {
let ev2 = ev.clone();
self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2));
@@ -1232,12 +1357,6 @@ impl ChatWidget {
self.add_to_history(history_cell::new_web_search_call(ev.query));
}
fn on_collab_event(&mut self, cell: PlainHistoryCell) {
self.flush_answer_stream_with_separator();
self.add_to_history(cell);
self.request_redraw();
}
fn on_get_history_entry_response(
&mut self,
event: codex_core::protocol::GetHistoryEntryResponseEvent,
@@ -1629,6 +1748,7 @@ impl ChatWidget {
app_event_tx,
initial_prompt,
initial_images,
initial_text_elements,
enhanced_keys_supported,
auth_manager,
models_manager,
@@ -1677,6 +1797,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 +1856,7 @@ impl ChatWidget {
app_event_tx,
initial_prompt,
initial_images,
initial_text_elements,
enhanced_keys_supported,
auth_manager,
models_manager,
@@ -1775,6 +1897,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,
@@ -1850,26 +1973,10 @@ impl ChatWidget {
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
} if c.eq_ignore_ascii_case(&'v')
&& modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
self.paste_image_from_clipboard();
return;
}
other if other.kind == KeyEventKind::Press => {
@@ -1889,46 +1996,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
_ => match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
text_elements,
} => {
let user_message = UserMessage {
text,
local_images: self
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
};
if self.is_session_configured() {
// Submitted is only emitted when steer is enabled (Enter sends immediately).
// Reset any reasoning header only when we are actually submitting a turn.
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(),
};
self.submit_user_message(user_message);
} else {
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 => {}
}
}
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 => {}
},
}
}
@@ -1938,6 +2063,32 @@ impl ChatWidget {
self.request_redraw();
}
/// Attempt to attach an image from the system clipboard.
///
/// This is a best-effort path used when we receive an empty paste event,
/// which some terminals emit when the clipboard contains non-text data
/// (like images). When the clipboard can't be read or no image exists,
/// surface a helpful follow-up so the user can retry with a file path.
fn paste_image_from_clipboard(&mut self) {
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}. Try saving the image to a file and pasting the file path instead.",
)));
}
}
}
pub(crate) fn composer_text_with_pending(&self) -> String {
self.bottom_pane.composer_text_with_pending()
}
@@ -2192,6 +2343,20 @@ impl ChatWidget {
self.bottom_pane.handle_paste(text);
}
/// Route paste events through image detection.
///
/// Terminals vary in how they represent paste: some emit an empty paste
/// payload when the clipboard isn't text (common for image-only clipboard
/// contents). Treat the empty payload as a hint to attempt a clipboard
/// image read; otherwise, fall back to text handling.
pub(crate) fn handle_paste_event(&mut self, text: String) {
if text.is_empty() {
self.paste_image_from_clipboard();
} else {
self.handle_paste(text);
}
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool {
if self.bottom_pane.flush_paste_burst_if_due() {
@@ -2251,8 +2416,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 +2445,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 +2488,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;
@@ -2459,16 +2634,16 @@ impl ChatWidget {
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::CollabAgentSpawnBegin(_) => {}
EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)),
EventMsg::CollabAgentInteractionBegin(_) => {}
EventMsg::CollabAgentInteractionEnd(ev) => {
self.on_collab_event(collab::interaction_end(ev))
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
}
EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)),
EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)),
EventMsg::CollabCloseBegin(_) => {}
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
EventMsg::ThreadRolledBack(_) => {}
EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
@@ -2533,10 +2708,16 @@ 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,
));
}
// User messages reset separator state so the next agent response doesn't add a stray break.
self.needs_final_message_separator = false;
}
/// Exit the UI immediately without waiting for shutdown.
@@ -4117,8 +4298,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) {
@@ -4594,14 +4781,6 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMe
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
path: skill.path.clone(),
scope: skill.scope,
})
+306 -25
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);
+85 -13
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)]
@@ -1358,6 +1432,7 @@ pub(crate) fn new_mcp_tools_output(
if tools.is_empty() {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
return PlainHistoryCell { lines };
}
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
@@ -1381,9 +1456,6 @@ pub(crate) fn new_mcp_tools_output(
header.push(" ".into());
header.push("(disabled)".red());
lines.push(header.into());
if let Some(reason) = cfg.disabled_reason.as_ref().map(ToString::to_string) {
lines.push(vec![" • Reason: ".into(), reason.dim()].into());
}
lines.push(Line::from(""));
continue;
}
@@ -1844,7 +1916,6 @@ mod tests {
cwd: None,
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
@@ -1865,7 +1936,6 @@ mod tests {
env_http_headers: Some(env_headers),
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
@@ -2581,6 +2651,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.
+9 -10
View File
@@ -45,7 +45,6 @@ mod bottom_pane;
mod chatwidget;
mod cli;
mod clipboard_paste;
mod collab;
mod color;
pub mod custom_terminal;
mod diff_render;
@@ -522,22 +521,22 @@ async fn run_ratatui_app(
}
} else if cli.resume_last {
let provider_filter = vec![config.model_provider_id.clone()];
let filter_cwd = if cli.resume_show_all {
None
} else {
Some(config.cwd.as_path())
};
match RolloutRecorder::find_latest_thread_path(
match RolloutRecorder::list_threads(
&config.codex_home,
1,
None,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
filter_cwd,
)
.await
{
Ok(Some(path)) => resume_picker::SessionSelection::Resume(path),
_ => resume_picker::SessionSelection::StartFresh,
Ok(page) => page
.items
.first()
.map(|it| resume_picker::SessionSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::SessionSelection::StartFresh),
Err(_) => resume_picker::SessionSelection::StartFresh,
}
} else if cli.resume_picker {
match resume_picker::run_resume_picker(
@@ -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();
+14
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> {
+11 -1
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>,
+322 -59
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,7 +1423,11 @@ 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.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.textarea.set_cursor(original_input.len());
return None;
}
@@ -1315,13 +1440,19 @@ 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.set_text_content(
original_input.clone(),
original_text_elements,
original_local_image_paths,
);
self.textarea.set_cursor(original_input.len());
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 +1460,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 +1509,43 @@ 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,
);
self.textarea.set_cursor(original_input.len());
(InputResult::None, true)
}
}
@@ -1488,7 +1642,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);
}
}
@@ -1736,6 +1890,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 +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);
}
}
@@ -2449,7 +2605,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 +2898,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 +3038,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 +3070,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 +3274,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 +3338,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 +3600,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 +3679,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 +3718,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 +3806,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 +4019,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 +4036,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 +4074,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();
@@ -4030,6 +4215,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 +4375,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 +4410,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 +4446,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 +4507,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 +4545,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 +4580,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 +4729,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 +4763,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 +4838,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 +4879,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 +5007,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 +5037,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 +5060,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 +5083,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) =
+36 -2
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)
+9 -27
View File
@@ -87,8 +87,12 @@ impl SkillPopup {
.into_iter()
.map(|(idx, indices, _score)| {
let skill = &self.skills[idx];
let name = truncate_text(skill_display_name(skill), 21);
let description = skill_description(skill).to_string();
let name = truncate_text(&skill.name, 21);
let description = skill
.short_description
.as_ref()
.unwrap_or(&skill.description)
.clone();
GenericDisplayRow {
name,
match_indices: indices,
@@ -112,20 +116,15 @@ impl SkillPopup {
}
for (idx, skill) in self.skills.iter().enumerate() {
let display_name = skill_display_name(skill);
if let Some((indices, score)) = fuzzy_match(display_name, filter) {
if let Some((indices, score)) = fuzzy_match(&skill.name, filter) {
out.push((idx, Some(indices), score));
} else if display_name != skill.name
&& let Some((_indices, score)) = fuzzy_match(&skill.name, filter)
{
out.push((idx, None, score));
}
}
out.sort_by(|a, b| {
a.2.cmp(&b.2).then_with(|| {
let an = skill_display_name(&self.skills[a.0]);
let bn = skill_display_name(&self.skills[b.0]);
let an = &self.skills[a.0].name;
let bn = &self.skills[b.0].name;
an.cmp(bn)
})
});
@@ -177,20 +176,3 @@ fn skill_popup_hint_line() -> Line<'static> {
" to close".into(),
])
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&skill.name)
}
fn skill_description(skill: &SkillMetadata) -> &str {
skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref())
.or(skill.short_description.as_deref())
.unwrap_or(&skill.description)
}
+41
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)?;
+250 -92
View File
@@ -85,12 +85,13 @@ use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WarningEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_core::skills::model::SkillInterface;
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 +123,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;
@@ -129,7 +131,6 @@ use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::collab;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
@@ -300,6 +301,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 +452,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 +471,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 +817,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 +994,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();
@@ -1032,12 +1164,6 @@ impl ChatWidget {
self.add_to_history(history_cell::new_web_search_call(ev.query));
}
fn on_collab_event(&mut self, cell: PlainHistoryCell) {
self.flush_answer_stream_with_separator();
self.add_to_history(cell);
self.request_redraw();
}
fn on_get_history_entry_response(
&mut self,
event: codex_core::protocol::GetHistoryEntryResponseEvent,
@@ -1434,6 +1560,7 @@ impl ChatWidget {
app_event_tx,
initial_prompt,
initial_images,
initial_text_elements,
enhanced_keys_supported,
auth_manager,
models_manager,
@@ -1482,6 +1609,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 +1665,7 @@ impl ChatWidget {
app_event_tx,
initial_prompt,
initial_images,
initial_text_elements,
enhanced_keys_supported,
auth_manager,
models_manager,
@@ -1578,6 +1707,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 +1819,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
_ => match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
text_elements,
} => {
let user_message = UserMessage {
text,
local_images: self
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
};
if self.is_session_configured() {
// Submitted is only emitted when steer is enabled (Enter sends immediately).
// Reset any reasoning header only when we are actually submitting a turn.
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(),
};
self.submit_user_message(user_message);
} else {
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 => {}
}
}
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 +2166,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 +2195,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 +2238,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;
}
@@ -2225,16 +2383,16 @@ impl ChatWidget {
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()),
EventMsg::CollabAgentSpawnBegin(_) => {}
EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)),
EventMsg::CollabAgentInteractionBegin(_) => {}
EventMsg::CollabAgentInteractionEnd(ev) => {
self.on_collab_event(collab::interaction_end(ev))
EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_) => {
// TODO(jif) handle collab tools.
}
EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)),
EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)),
EventMsg::CollabCloseBegin(_) => {}
EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)),
EventMsg::RawResponseItem(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::ItemStarted(_)
@@ -2295,10 +2453,12 @@ 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;
@@ -3806,8 +3966,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) {
@@ -4327,14 +4493,6 @@ fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<SkillMe
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
path: skill.path.clone(),
scope: skill.scope,
})
+306 -5
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);
+85 -13
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)]
@@ -1422,6 +1496,7 @@ pub(crate) fn new_mcp_tools_output(
if tools.is_empty() {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
return PlainHistoryCell { lines };
}
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
@@ -1445,9 +1520,6 @@ pub(crate) fn new_mcp_tools_output(
header.push(" ".into());
header.push("(disabled)".red());
lines.push(header.into());
if let Some(reason) = cfg.disabled_reason.as_ref().map(ToString::to_string) {
lines.push(vec![" • Reason: ".into(), reason.dim()].into());
}
lines.push(Line::from(""));
continue;
}
@@ -1979,7 +2051,6 @@ mod tests {
cwd: None,
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
@@ -2000,7 +2071,6 @@ mod tests {
env_http_headers: Some(env_headers),
},
enabled: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
@@ -2717,6 +2787,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.
+9 -10
View File
@@ -46,7 +46,6 @@ mod chatwidget;
mod cli;
mod clipboard_copy;
mod clipboard_paste;
mod collab;
mod color;
pub mod custom_terminal;
mod diff_render;
@@ -545,22 +544,22 @@ async fn run_ratatui_app(
}
} else if cli.resume_last {
let provider_filter = vec![config.model_provider_id.clone()];
let filter_cwd = if cli.resume_show_all {
None
} else {
Some(config.cwd.as_path())
};
match RolloutRecorder::find_latest_thread_path(
match RolloutRecorder::list_threads(
&config.codex_home,
1,
None,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
filter_cwd,
)
.await
{
Ok(Some(path)) => resume_picker::SessionSelection::Resume(path),
_ => resume_picker::SessionSelection::StartFresh,
Ok(page) => page
.items
.first()
.map(|it| resume_picker::SessionSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::SessionSelection::StartFresh),
Err(_) => resume_picker::SessionSelection::StartFresh,
}
} else if cli.resume_picker {
match resume_picker::run_resume_picker(
@@ -48,13 +48,14 @@ impl ComposerInput {
/// Clear the input text.
pub fn clear(&mut self) {
self.inner.set_text_content(String::new());
self.inner
.set_text_content(String::new(), Vec::new(), Vec::new());
}
/// Feed a key event into the composer and return a high-level action.
pub fn input(&mut self, key: KeyEvent) -> ComposerAction {
let action = match self.inner.handle_key_event(key).0 {
InputResult::Submitted(text) => ComposerAction::Submitted(text),
InputResult::Submitted { text, .. } => ComposerAction::Submitted(text),
_ => ComposerAction::None,
};
self.drain_app_events();
@@ -1018,6 +1018,8 @@ mod tests {
let mut cache = TranscriptViewCache::new();
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(UserHistoryCell {
message: "hello".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
})];
cache.ensure_wrapped(&cells, 20);