From e50f924e4f2be47012d6641221c2afbfa311482e Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 15 Jan 2026 15:29:38 -0800 Subject: [PATCH] Wire text elements through TUI and TUI2 --- codex-rs/tui/src/app.rs | 14 + codex-rs/tui/src/app_backtrack.rs | 12 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 376 +++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 38 +- codex-rs/tui/src/bottom_pane/skill_popup.rs | 36 +- codex-rs/tui/src/bottom_pane/textarea.rs | 41 ++ codex-rs/tui/src/chatwidget.rs | 409 +++++++++++++----- codex-rs/tui/src/chatwidget/tests.rs | 331 ++++++++++++-- codex-rs/tui/src/history_cell.rs | 98 ++++- codex-rs/tui/src/lib.rs | 19 +- .../tui/src/public_widgets/composer_input.rs | 5 +- codex-rs/tui2/src/app.rs | 14 + codex-rs/tui2/src/app_backtrack.rs | 12 +- .../tui2/src/bottom_pane/chat_composer.rs | 381 +++++++++++++--- codex-rs/tui2/src/bottom_pane/mod.rs | 38 +- codex-rs/tui2/src/bottom_pane/skill_popup.rs | 36 +- codex-rs/tui2/src/bottom_pane/textarea.rs | 41 ++ codex-rs/tui2/src/chatwidget.rs | 342 +++++++++++---- codex-rs/tui2/src/chatwidget/tests.rs | 311 ++++++++++++- codex-rs/tui2/src/history_cell.rs | 98 ++++- codex-rs/tui2/src/lib.rs | 19 +- .../tui2/src/public_widgets/composer_input.rs | 5 +- codex-rs/tui2/src/transcript_view_cache.rs | 2 + 23 files changed, 2214 insertions(+), 464 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 87d76909de..cd825f3423 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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 { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 43dacb573a..97bd617122 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -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> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -430,6 +434,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -458,11 +464,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 343403704e..53e88c1eea 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, + }, + Queued { + text: String, + text_elements: Vec, + }, 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, + local_image_paths: Vec, + ) { // 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 = 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 { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + 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 { 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 { + 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, + ) -> Vec { + 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 { + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { // 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::>(); + 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::>(); 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::(); 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::(); + 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) = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f270145a04..745c9be513 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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, + local_image_paths: Vec, + ) { + 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 { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + 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 { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 867c10a8bf..8e894de45e 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -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) -} diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 903ebe9f82..134705cba9 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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 { + 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 { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d566356e2..5850969137 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, pub(crate) initial_images: Vec, + pub(crate) initial_text_elements: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -508,14 +510,17 @@ pub(crate) struct ActiveCellTranscriptKey { struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From 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) -> Option { - if text.is_empty() && image_paths.is_empty() { +fn create_initial_user_message( + text: String, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + 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, + local_images: Vec, + next_label: &mut usize, +) -> (String, Vec, Vec) { + if local_images.is_empty() { + return (text.to_string(), text_elements, Vec::new()); + } + + let mut mapping: HashMap = 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, Vec)> = self .queued_user_messages .iter() - .map(|m| m.text.clone()) - .collect::>() - .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, + local_image_paths: Vec, + ) { + 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() + { + 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::() + { + 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); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c060d20cd9..75e5e149af 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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, + pub local_image_paths: Vec, } 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> = 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> = 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, + local_image_paths: Vec, +) -> 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. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 29bf2b1ce0..4bae970ee3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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( diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -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(); diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index cd174fbbbd..1510c8e559 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -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 { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index 1f5ec3e45e..d6da44468c 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -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> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -450,6 +454,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -478,11 +484,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index c9058e0153..6b1e59ef1e 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -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, + }, + Queued { + text: String, + text_elements: Vec, + }, 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, + local_image_paths: Vec, + ) { // 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 = 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 { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + 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 { 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 { + 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, + ) -> Vec { + 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 { + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { // 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::>(); + 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::>(); 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::(); 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::(); + 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) = diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index df9c0d84cd..e4e3c769d0 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -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, + local_image_paths: Vec, + ) { + 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 { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + 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 { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui2/src/bottom_pane/skill_popup.rs b/codex-rs/tui2/src/bottom_pane/skill_popup.rs index d4d4a6828f..faf386e29c 100644 --- a/codex-rs/tui2/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/skill_popup.rs @@ -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) -} diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 903ebe9f82..134705cba9 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -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 { + 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 { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 5facd756f7..cd408df51b 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -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, pub(crate) initial_images: Vec, + pub(crate) initial_text_elements: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, @@ -450,14 +452,17 @@ pub(crate) struct ActiveCellTranscriptKey { struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From 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) -> Option { - if text.is_empty() && image_paths.is_empty() { +fn create_initial_user_message( + text: String, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + 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, + local_images: Vec, + next_label: &mut usize, +) -> (String, Vec, Vec) { + 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 = 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) { 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, Vec)> = self .queued_user_messages .iter() - .map(|m| m.text.clone()) - .collect::>() - .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, + local_image_paths: Vec, + ) { + 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() + { + 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::() + { + 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); diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index e3334a0681..cffe4ad3a1 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -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, + pub local_image_paths: Vec, } 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> = 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> = 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> = Vec::new(); let mut joins: Vec> = 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, + local_image_paths: Vec, +) -> 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. diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 55e640c58f..ddb38cf29d 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -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( diff --git a/codex-rs/tui2/src/public_widgets/composer_input.rs b/codex-rs/tui2/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui2/src/public_widgets/composer_input.rs +++ b/codex-rs/tui2/src/public_widgets/composer_input.rs @@ -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(); diff --git a/codex-rs/tui2/src/transcript_view_cache.rs b/codex-rs/tui2/src/transcript_view_cache.rs index a32094b118..ab5d94ee86 100644 --- a/codex-rs/tui2/src/transcript_view_cache.rs +++ b/codex-rs/tui2/src/transcript_view_cache.rs @@ -1018,6 +1018,8 @@ mod tests { let mut cache = TranscriptViewCache::new(); let cells: Vec> = vec![Arc::new(UserHistoryCell { message: "hello".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), })]; cache.ensure_wrapped(&cells, 20);