diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 39090c022..488862b84 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -503,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 563a78cc6..0635b530e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1665,24 +1665,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index be2971397..89b127d39 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -74,10 +74,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 29a7d6b93..c46544470 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0d..60d0dbc75 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 47b67cfe3..d0718a334 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b..9276a759c 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d8..0f32f5a33 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,35 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + /// Returns the stored placeholder without falling back to the text buffer. + /// + /// This is intended only for protocol conversions where the full text is not + /// available; prefer `placeholder(text)` for UI logic. + #[doc(hidden)] + pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 55b40a9a0..d54e7005e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -557,12 +557,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1240,13 +1235,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1287,7 +1282,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1301,13 +1296,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4232,7 +4227,7 @@ mod tests { } => { 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].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4261,10 +4256,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4301,7 +4293,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4345,7 +4337,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4389,7 +4381,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4474,7 +4466,7 @@ mod tests { } => { 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].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4664,20 +4656,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 926c53601..3a849822b 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,13 +755,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 48ed806d3..8fb3dbacb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -614,9 +614,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -624,8 +624,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 46f5c3be9..0610a640c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -192,10 +192,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -274,10 +274,10 @@ async fn submission_preserves_text_elements_and_local_images() { 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 text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -330,26 +330,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { 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_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + 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_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + 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_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -392,18 +392,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { 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()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -422,14 +422,14 @@ async fn remap_placeholders_uses_attachment_labels() { 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()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -454,15 +454,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_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()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -486,14 +485,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { 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: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -518,15 +514,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_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()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 3592b2573..34a4795de 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -489,12 +489,7 @@ impl ChatComposer { let image_placeholders: HashSet = text_elements .iter() - .filter_map(|elem| { - elem.placeholder.as_ref().cloned().or_else(|| { - text.get(elem.byte_range.start..elem.byte_range.end) - .map(str::to_string) - }) - }) + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) .collect(); for (idx, path) in local_image_paths.into_iter().enumerate() { let placeholder = local_image_label_text(idx + 1); @@ -1174,13 +1169,13 @@ impl ChatComposer { return None; } let placeholder = trimmed.get(new_start..new_end).map(str::to_string); - Some(TextElement { - byte_range: ByteRange { + Some(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }) + )) }) .collect() } @@ -1219,7 +1214,7 @@ impl ChatComposer { rebuilt.push_str(&text[cursor..start]); } let elem_text = &text[start..end]; - let placeholder = elem.placeholder; + let placeholder = elem.placeholder(text).map(str::to_string); let replacement = placeholder .as_deref() .and_then(|ph| pending_by_placeholder.get_mut(ph)) @@ -1231,13 +1226,13 @@ impl ChatComposer { rebuilt.push_str(elem_text); let new_end = rebuilt.len(); let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); - rebuilt_elements.push(TextElement { - byte_range: ByteRange { + rebuilt_elements.push(TextElement::new( + ByteRange { start: new_start, end: new_end, }, placeholder, - }); + )); } cursor = end; } @@ -4223,7 +4218,7 @@ mod tests { } => { 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].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4252,10 +4247,7 @@ mod tests { let placeholder = local_image_label_text(1); let text = format!("{placeholder} restored"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: None, - }]; + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; let path = PathBuf::from("/tmp/image1.png"); composer.set_text_content(text, text_elements, vec![path.clone()]); @@ -4292,7 +4284,7 @@ mod tests { let expected = format!("{large_content} [Image #1]"); assert_eq!(text, expected); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4336,7 +4328,7 @@ mod tests { let trimmed = large_content.trim().to_string(); assert_eq!(text, format!("{trimmed} [Image #1]")); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4380,7 +4372,7 @@ mod tests { assert_eq!(text, "line1\nline2\n [Image #1]"); assert!(!text.contains('\r')); assert_eq!(text_elements.len(), 1); - assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]")); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4465,7 +4457,7 @@ mod tests { } => { 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].placeholder(&text), Some("[Image #1]")); assert_eq!( text_elements[0].byte_range, ByteRange { @@ -4627,20 +4619,20 @@ mod tests { let start2 = text.find(&placeholder2).expect("placeholder2 present"); let start1 = text.find(&placeholder1).expect("placeholder1 present"); let text_elements = vec![ - TextElement { - byte_range: ByteRange { + TextElement::new( + ByteRange { start: start2, end: start2 + placeholder2.len(), }, - placeholder: Some(placeholder2), - }, - TextElement { - byte_range: ByteRange { + Some(placeholder2), + ), + TextElement::new( + ByteRange { start: start1, end: start1 + placeholder1.len(), }, - placeholder: Some(placeholder1.clone()), - }, + Some(placeholder1.clone()), + ), ]; composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index 37f2d54c7..7c39379c9 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -752,13 +752,13 @@ impl TextArea { .iter() .map(|e| { let placeholder = self.text.get(e.range.clone()).map(str::to_string); - UserTextElement { - byte_range: ByteRange { + UserTextElement::new( + ByteRange { start: e.range.start, end: e.range.end, }, placeholder, - } + ) }) .collect() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c4cdf7669..23c6195ac 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -556,9 +556,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) } let original = text.get(start..end).unwrap_or(""); - let placeholder_key = elem.placeholder.as_deref().unwrap_or(original); - let replacement = mapping - .get(placeholder_key) + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) .map(String::as_str) .unwrap_or(original); @@ -566,8 +566,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) rebuilt.push_str(replacement); let elem_end = rebuilt.len(); - if let Some(remapped) = mapping.get(placeholder_key) { - elem.placeholder = Some(remapped.clone()); + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); } elem.byte_range = (elem_start..elem_end).into(); rebuilt_elements.push(elem); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index f1cf4c32e..005b015a6 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -181,10 +181,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); - let text_elements = vec![TextElement { - byte_range: (0..placeholder.len()).into(), - placeholder: Some(placeholder.to_string()), - }]; + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let conversation_id = ThreadId::new(); @@ -263,10 +263,10 @@ async fn submission_preserves_text_elements_and_local_images() { 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 text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane @@ -319,26 +319,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { 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_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + 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_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + 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_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; chat.queued_user_messages.push_back(UserMessage { @@ -381,18 +381,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { 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()), - }, + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ]; assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( @@ -411,14 +411,14 @@ async fn remap_placeholders_uses_attachment_labels() { 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()), - }, + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), ]; let attachments = vec![ @@ -443,15 +443,14 @@ async fn remap_placeholders_uses_attachment_labels() { assert_eq!( remapped.text_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()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!( @@ -475,14 +474,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { 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: None, - }, - TextElement { - byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), - placeholder: None, - }, + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), ]; let attachments = vec![ @@ -507,15 +503,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { assert_eq!( remapped.text_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()), - }, + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), ] ); assert_eq!(