diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
index 6fa6dfabbd4b..6772d6f92a59 100644
--- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs
+++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
@@ -197,6 +197,8 @@ impl ThreadHistoryBuilder {
         if !payload.message.trim().is_empty() {
             content.push(UserInput::Text {
                 text: payload.message.clone(),
+                // TODO: Thread text element ranges into thread history. Empty keeps old behavior.
+                text_elements: Vec::new(),
             });
         }
         if let Some(images) = &payload.images {
@@ -244,6 +246,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "First turn".into(),
                 images: Some(vec!["https://example.com/one.png".into()]),
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "Hi there".into(),
@@ -257,6 +261,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Second turn".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "Reply two".into(),
@@ -277,6 +283,7 @@ mod tests {
                 content: vec![
                     UserInput::Text {
                         text: "First turn".into(),
+                        text_elements: Vec::new(),
                     },
                     UserInput::Image {
                         url: "https://example.com/one.png".into(),
@@ -308,7 +315,8 @@ mod tests {
             ThreadItem::UserMessage {
                 id: "item-4".into(),
                 content: vec![UserInput::Text {
-                    text: "Second turn".into()
+                    text: "Second turn".into(),
+                    text_elements: Vec::new(),
                 }],
             }
         );
@@ -327,6 +335,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Turn start".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentReasoning(AgentReasoningEvent {
                 text: "first summary".into(),
@@ -371,6 +381,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Please do the thing".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "Working...".into(),
@@ -381,6 +393,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Let's try again".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "Second attempt complete.".into(),
@@ -398,7 +412,8 @@ mod tests {
             ThreadItem::UserMessage {
                 id: "item-1".into(),
                 content: vec![UserInput::Text {
-                    text: "Please do the thing".into()
+                    text: "Please do the thing".into(),
+                    text_elements: Vec::new(),
                 }],
             }
         );
@@ -418,7 +433,8 @@ mod tests {
             ThreadItem::UserMessage {
                 id: "item-3".into(),
                 content: vec![UserInput::Text {
-                    text: "Let's try again".into()
+                    text: "Let's try again".into(),
+                    text_elements: Vec::new(),
                 }],
             }
         );
@@ -437,6 +453,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "First".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "A1".into(),
@@ -444,6 +462,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Second".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "A2".into(),
@@ -452,6 +472,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Third".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "A3".into(),
@@ -469,6 +491,7 @@ mod tests {
                         id: "item-1".into(),
                         content: vec![UserInput::Text {
                             text: "First".into(),
+                            text_elements: Vec::new(),
                         }],
                     },
                     ThreadItem::AgentMessage {
@@ -486,6 +509,7 @@ mod tests {
                         id: "item-3".into(),
                         content: vec![UserInput::Text {
                             text: "Third".into(),
+                            text_elements: Vec::new(),
                         }],
                     },
                     ThreadItem::AgentMessage {
@@ -504,6 +528,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "One".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "A1".into(),
@@ -511,6 +537,8 @@ mod tests {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "Two".into(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "A2".into(),
diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs
index 17a367e66ff3..d8a0e8a6e6eb 100644
--- a/codex-rs/app-server-protocol/src/protocol/v2.rs
+++ b/codex-rs/app-server-protocol/src/protocol/v2.rs
@@ -1543,21 +1543,55 @@ pub struct TurnInterruptParams {
 pub struct TurnInterruptResponse {}
 
 // User input types
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export_to = "v2/")]
+pub struct ByteRange {
+    pub start: usize,
+    pub end: usize,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+#[serde(rename_all = "camelCase")]
+#[ts(export_to = "v2/")]
+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<String>,
+}
+
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
 #[serde(tag = "type", rename_all = "camelCase")]
 #[ts(tag = "type")]
 #[ts(export_to = "v2/")]
 pub enum UserInput {
-    Text { text: String },
-    Image { url: String },
-    LocalImage { path: PathBuf },
-    Skill { name: String, path: PathBuf },
+    Text {
+        text: String,
+        /// UI-defined spans within `text` used to render or persist special elements.
+        #[serde(default)]
+        text_elements: Vec<TextElement>,
+    },
+    Image {
+        url: String,
+    },
+    LocalImage {
+        path: PathBuf,
+    },
+    Skill {
+        name: String,
+        path: PathBuf,
+    },
 }
 
 impl UserInput {
     pub fn into_core(self) -> CoreUserInput {
         match self {
-            UserInput::Text { text } => CoreUserInput::Text { text },
+            UserInput::Text { text, .. } => CoreUserInput::Text {
+                text,
+                // TODO: Thread text element ranges into v2 inputs. Empty keeps old behavior.
+                text_elements: Vec::new(),
+            },
             UserInput::Image { url } => CoreUserInput::Image { image_url: url },
             UserInput::LocalImage { path } => CoreUserInput::LocalImage { path },
             UserInput::Skill { name, path } => CoreUserInput::Skill { name, path },
@@ -1568,7 +1602,11 @@ impl UserInput {
 impl From<CoreUserInput> for UserInput {
     fn from(value: CoreUserInput) -> Self {
         match value {
-            CoreUserInput::Text { text } => UserInput::Text { text },
+            CoreUserInput::Text { text, .. } => UserInput::Text {
+                text,
+                // TODO: Thread text element ranges from core into v2 inputs.
+                text_elements: Vec::new(),
+            },
             CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
             CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
             CoreUserInput::Skill { name, path } => UserInput::Skill { name, path },
@@ -2160,6 +2198,7 @@ mod tests {
             content: vec![
                 CoreUserInput::Text {
                     text: "hello".to_string(),
+                    text_elements: Vec::new(),
                 },
                 CoreUserInput::Image {
                     image_url: "https://example.com/image.png".to_string(),
@@ -2181,6 +2220,7 @@ mod tests {
                 content: vec![
                     UserInput::Text {
                         text: "hello".to_string(),
+                        text_elements: Vec::new(),
                     },
                     UserInput::Image {
                         url: "https://example.com/image.png".to_string(),
diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs
index 283a01b2c3aa..809c2b5a3385 100644
--- a/codex-rs/app-server-test-client/src/main.rs
+++ b/codex-rs/app-server-test-client/src/main.rs
@@ -256,7 +256,11 @@ fn send_message_v2_with_policies(
     println!("< thread/start response: {thread_response:?}");
     let mut turn_params = TurnStartParams {
         thread_id: thread_response.thread.id.clone(),
-        input: vec![V2UserInput::Text { text: user_message }],
+        input: vec![V2UserInput::Text {
+            text: user_message,
+            // Plain text conversion has no UI element ranges.
+            text_elements: Vec::new(),
+        }],
         ..Default::default()
     };
     turn_params.approval_policy = approval_policy;
@@ -288,6 +292,7 @@ fn send_follow_up_v2(
         thread_id: thread_response.thread.id.clone(),
         input: vec![V2UserInput::Text {
             text: first_message,
+            text_elements: Vec::new(),
         }],
         ..Default::default()
     };
@@ -299,6 +304,7 @@ fn send_follow_up_v2(
         thread_id: thread_response.thread.id.clone(),
         input: vec![V2UserInput::Text {
             text: follow_up_message,
+            text_elements: Vec::new(),
         }],
         ..Default::default()
     };
diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs
index d3be7c99b51d..0a64ed7191e9 100644
--- a/codex-rs/app-server/src/codex_message_processor.rs
+++ b/codex-rs/app-server/src/codex_message_processor.rs
@@ -3125,7 +3125,11 @@ impl CodexMessageProcessor {
         let mapped_items: Vec<CoreInputItem> = items
             .into_iter()
             .map(|item| match item {
-                WireInputItem::Text { text } => CoreInputItem::Text { text },
+                WireInputItem::Text { text } => CoreInputItem::Text {
+                    text,
+                    // TODO: Thread text element ranges into v1 input handling.
+                    text_elements: Vec::new(),
+                },
                 WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
                 WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
             })
@@ -3171,7 +3175,11 @@ impl CodexMessageProcessor {
         let mapped_items: Vec<CoreInputItem> = items
             .into_iter()
             .map(|item| match item {
-                WireInputItem::Text { text } => CoreInputItem::Text { text },
+                WireInputItem::Text { text } => CoreInputItem::Text {
+                    text,
+                    // TODO: Thread text element ranges into v1 input handling.
+                    text_elements: Vec::new(),
+                },
                 WireInputItem::Image { image_url } => CoreInputItem::Image { image_url },
                 WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path },
             })
@@ -3333,6 +3341,7 @@ impl CodexMessageProcessor {
                 id: turn_id.clone(),
                 content: vec![V2UserInput::Text {
                     text: display_text.to_string(),
+                    text_elements: Vec::new(),
                 }],
             }]
         };
diff --git a/codex-rs/app-server/tests/suite/v2/output_schema.rs b/codex-rs/app-server/tests/suite/v2/output_schema.rs
index f23c03703778..149e098b686f 100644
--- a/codex-rs/app-server/tests/suite/v2/output_schema.rs
+++ b/codex-rs/app-server/tests/suite/v2/output_schema.rs
@@ -61,6 +61,7 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Hello".to_string(),
+                text_elements: Vec::new(),
             }],
             output_schema: Some(output_schema.clone()),
             ..Default::default()
@@ -142,6 +143,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Hello".to_string(),
+                text_elements: Vec::new(),
             }],
             output_schema: Some(output_schema.clone()),
             ..Default::default()
@@ -183,6 +185,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Hello again".to_string(),
+                text_elements: Vec::new(),
             }],
             output_schema: None,
             ..Default::default()
diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs
index a5445998f013..32abc237e3ed 100644
--- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs
+++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs
@@ -95,7 +95,8 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
             assert_eq!(
                 content,
                 &vec![UserInput::Text {
-                    text: preview.to_string()
+                    text: preview.to_string(),
+                    text_elements: Vec::new(),
                 }]
             );
         }
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 483095a98f85..cfcfcedf70fb 100644
--- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs
+++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs
@@ -118,7 +118,8 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
             assert_eq!(
                 content,
                 &vec![UserInput::Text {
-                    text: preview.to_string()
+                    text: preview.to_string(),
+                    text_elements: Vec::new(),
                 }]
             );
         }
diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs
index e88c065f7de8..6e3767db93d5 100644
--- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs
+++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs
@@ -57,6 +57,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: first_text.to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
@@ -77,6 +78,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Second".to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
@@ -115,7 +117,8 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
             assert_eq!(
                 content,
                 &vec![V2UserInput::Text {
-                    text: first_text.to_string()
+                    text: first_text.to_string(),
+                    text_elements: Vec::new(),
                 }]
             );
         }
@@ -143,7 +146,8 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<()
             assert_eq!(
                 content,
                 &vec![V2UserInput::Text {
-                    text: first_text.to_string()
+                    text: first_text.to_string(),
+                    text_elements: Vec::new(),
                 }]
             );
         }
diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs
index 34587793ce10..9c804aa666eb 100644
--- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs
+++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs
@@ -73,6 +73,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "run sleep".to_string(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(working_directory.clone()),
             ..Default::default()
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 d798e7d34e42..406f80328ba0 100644
--- a/codex-rs/app-server/tests/suite/v2/turn_start.rs
+++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs
@@ -80,6 +80,7 @@ async fn turn_start_sends_originator_header() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Hello".to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
@@ -149,6 +150,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Hello".to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
@@ -181,6 +183,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "Second".to_string(),
+                text_elements: Vec::new(),
             }],
             model: Some("mock-model-override".to_string()),
             ..Default::default()
@@ -331,6 +334,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "run python".to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
@@ -376,6 +380,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "run python again".to_string(),
+                text_elements: Vec::new(),
             }],
             approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
             sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
@@ -452,6 +457,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "run python".to_string(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(workspace.clone()),
             ..Default::default()
@@ -600,6 +606,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "first turn".to_string(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(first_cwd.clone()),
             approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
@@ -633,6 +640,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "second turn".to_string(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(second_cwd.clone()),
             approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
@@ -733,6 +741,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "apply patch".into(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(workspace.clone()),
             ..Default::default()
@@ -910,6 +919,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "apply patch 1".into(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(workspace.clone()),
             ..Default::default()
@@ -986,6 +996,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "apply patch 2".into(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(workspace.clone()),
             ..Default::default()
@@ -1083,6 +1094,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> {
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "apply patch".into(),
+                text_elements: Vec::new(),
             }],
             cwd: Some(workspace.clone()),
             ..Default::default()
@@ -1230,6 +1242,7 @@ unified_exec = true
             thread_id: thread.id.clone(),
             input: vec![V2UserInput::Text {
                 text: "run a command".to_string(),
+                text_elements: Vec::new(),
             }],
             ..Default::default()
         })
diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs
index f547a6d4dd50..c8ba7fdda16a 100644
--- a/codex-rs/core/src/agent/control.rs
+++ b/codex-rs/core/src/agent/control.rs
@@ -56,7 +56,11 @@ impl AgentControl {
             .send_op(
                 agent_id,
                 Op::UserInput {
-                    items: vec![UserInput::Text { text: prompt }],
+                    items: vec![UserInput::Text {
+                        text: prompt,
+                        // Plain text conversion has no UI element ranges.
+                        text_elements: Vec::new(),
+                    }],
                     final_output_json_schema: None,
                 },
             )
@@ -321,6 +325,7 @@ mod tests {
             Op::UserInput {
                 items: vec![UserInput::Text {
                     text: "hello from tests".to_string(),
+                    text_elements: Vec::new(),
                 }],
                 final_output_json_schema: None,
             },
@@ -351,6 +356,7 @@ mod tests {
             Op::UserInput {
                 items: vec![UserInput::Text {
                     text: "spawned".to_string(),
+                    text_elements: Vec::new(),
                 }],
                 final_output_json_schema: None,
             },
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index da0719b0011d..158443ee49fd 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -2260,6 +2260,7 @@ mod handlers {
             Arc::clone(&turn_context),
             vec![UserInput::Text {
                 text: turn_context.compact_prompt().to_string(),
+                text_elements: Vec::new(),
             }],
             CompactTask,
         )
@@ -2470,6 +2471,7 @@ async fn spawn_review_thread(
     // Seed the child task with the review prompt as the initial user message.
     let input: Vec<UserInput> = vec![UserInput::Text {
         text: review_prompt,
+        text_elements: Vec::new(),
     }];
     let tc = Arc::new(review_turn_context);
     sess.spawn_task(tc.clone(), input, ReviewTask::new()).await;
@@ -3964,6 +3966,7 @@ mod tests {
         let (sess, tc, rx) = make_session_and_context_with_rx().await;
         let input = vec![UserInput::Text {
             text: "hello".to_string(),
+            text_elements: Vec::new(),
         }];
         sess.spawn_task(
             Arc::clone(&tc),
@@ -3993,6 +3996,7 @@ mod tests {
         let (sess, tc, rx) = make_session_and_context_with_rx().await;
         let input = vec![UserInput::Text {
             text: "hello".to_string(),
+            text_elements: Vec::new(),
         }];
         sess.spawn_task(
             Arc::clone(&tc),
@@ -4019,6 +4023,7 @@ mod tests {
         let (sess, tc, rx) = make_session_and_context_with_rx().await;
         let input = vec![UserInput::Text {
             text: "start review".to_string(),
+            text_elements: Vec::new(),
         }];
         sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new())
             .await;
diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs
index 120e701bbc9a..4dc56f10d25a 100644
--- a/codex-rs/core/src/compact.rs
+++ b/codex-rs/core/src/compact.rs
@@ -44,7 +44,11 @@ pub(crate) async fn run_inline_auto_compact_task(
     turn_context: Arc<TurnContext>,
 ) {
     let prompt = turn_context.compact_prompt().to_string();
-    let input = vec![UserInput::Text { text: prompt }];
+    let input = vec![UserInput::Text {
+        text: prompt,
+        // Plain text conversion has no UI element ranges.
+        text_elements: Vec::new(),
+    }];
 
     run_compact_task_inner(sess, turn_context, input).await;
 }
diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs
index fe592236c116..6c19c726df31 100644
--- a/codex-rs/core/src/event_mapping.rs
+++ b/codex-rs/core/src/event_mapping.rs
@@ -50,7 +50,11 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
                 if is_session_prefix(text) || is_user_shell_command_text(text) {
                     return None;
                 }
-                content.push(UserInput::Text { text: text.clone() });
+                content.push(UserInput::Text {
+                    text: text.clone(),
+                    // Plain text conversion has no UI element ranges.
+                    text_elements: Vec::new(),
+                });
             }
             ContentItem::InputImage { image_url } => {
                 content.push(UserInput::Image {
@@ -179,6 +183,7 @@ mod tests {
                 let expected_content = vec![
                     UserInput::Text {
                         text: "Hello world".to_string(),
+                        text_elements: Vec::new(),
                     },
                     UserInput::Image { image_url: img1 },
                     UserInput::Image { image_url: img2 },
@@ -218,7 +223,10 @@ mod tests {
             TurnItem::UserMessage(user) => {
                 let expected_content = vec![
                     UserInput::Image { image_url },
-                    UserInput::Text { text: user_text },
+                    UserInput::Text {
+                        text: user_text,
+                        text_elements: Vec::new(),
+                    },
                 ];
                 assert_eq!(user.content, expected_content);
             }
@@ -255,7 +263,10 @@ mod tests {
             TurnItem::UserMessage(user) => {
                 let expected_content = vec![
                     UserInput::Image { image_url },
-                    UserInput::Text { text: user_text },
+                    UserInput::Text {
+                        text: user_text,
+                        text_elements: Vec::new(),
+                    },
                 ];
                 assert_eq!(user.content, expected_content);
             }
diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs
index f7c13c70f8a7..c16ec65cf807 100644
--- a/codex-rs/core/src/rollout/tests.rs
+++ b/codex-rs/core/src/rollout/tests.rs
@@ -603,6 +603,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
         item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
             message: "hello".into(),
             images: None,
+            text_elements: Vec::new(),
+            local_images: Vec::new(),
         })),
     };
     writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?;
diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs
index 6d59cd4df365..7bf081948853 100644
--- a/codex-rs/core/tests/common/test_codex.rs
+++ b/codex-rs/core/tests/common/test_codex.rs
@@ -270,6 +270,7 @@ impl TestCodex {
             .submit(Op::UserTurn {
                 items: vec![UserInput::Text {
                     text: prompt.into(),
+                    text_elements: Vec::new(),
                 }],
                 final_output_json_schema: None,
                 cwd: self.cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs
index 094c10c77866..c020fa00cbf2 100644
--- a/codex-rs/core/tests/suite/abort_tasks.rs
+++ b/codex-rs/core/tests/suite/abort_tasks.rs
@@ -48,6 +48,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "start sleep".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -101,6 +102,7 @@ async fn interrupt_tool_records_history_entries() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "start history recording".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -118,6 +120,7 @@ async fn interrupt_tool_records_history_entries() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "follow up".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs
index f4515a9631e3..5f764d227a55 100644
--- a/codex-rs/core/tests/suite/apply_patch_cli.rs
+++ b/codex-rs/core/tests/suite/apply_patch_cli.rs
@@ -302,6 +302,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "rename without content change".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -888,6 +889,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "apply via shell heredoc with cd".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -965,6 +967,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "apply patch via shell".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1112,6 +1115,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "emit diff".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1172,6 +1176,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "rename with change".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1240,6 +1245,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "aggregate diffs".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1308,6 +1314,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "apply patch twice with failure".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs
index 51f1c31f17ba..357fdd124ab0 100644
--- a/codex-rs/core/tests/suite/approvals.rs
+++ b/codex-rs/core/tests/suite/approvals.rs
@@ -492,6 +492,7 @@ async fn submit_turn(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: prompt.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: test.cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index ecb8dcbbf2bd..18d3caf3bfcb 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -289,6 +289,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -387,6 +388,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -440,6 +442,7 @@ async fn includes_base_instructions_override_in_request() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -496,6 +499,7 @@ async fn chatgpt_auth_sends_correct_request() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -588,6 +592,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -629,6 +634,7 @@ async fn includes_user_instructions_message_in_request() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -708,6 +714,7 @@ async fn skills_append_to_instructions() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -760,6 +767,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -797,6 +805,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -832,6 +841,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -871,6 +881,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -910,6 +921,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -943,6 +955,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -983,6 +996,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1022,6 +1036,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1077,6 +1092,7 @@ async fn includes_developer_instructions_message_in_request() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1326,6 +1342,7 @@ async fn token_count_includes_rate_limits_snapshot() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1484,6 +1501,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1554,6 +1572,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "seed turn".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1565,6 +1584,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "trigger context window".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1685,6 +1705,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1768,6 +1789,7 @@ async fn env_var_overrides_loaded_auth() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1840,7 +1862,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
     // Turn 1: user sends U1; wait for completion.
     codex
         .submit(Op::UserInput {
-            items: vec![UserInput::Text { text: "U1".into() }],
+            items: vec![UserInput::Text {
+                text: "U1".into(),
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
         })
         .await
@@ -1850,7 +1875,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
     // Turn 2: user sends U2; wait for completion.
     codex
         .submit(Op::UserInput {
-            items: vec![UserInput::Text { text: "U2".into() }],
+            items: vec![UserInput::Text {
+                text: "U2".into(),
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
         })
         .await
@@ -1860,7 +1888,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
     // Turn 3: user sends U3; wait for completion.
     codex
         .submit(Op::UserInput {
-            items: vec![UserInput::Text { text: "U3".into() }],
+            items: vec![UserInput::Text {
+                text: "U3".into(),
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
         })
         .await
diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs
index e3c8e0b7c23b..79bac55a8b08 100644
--- a/codex-rs/core/tests/suite/compact.rs
+++ b/codex-rs/core/tests/suite/compact.rs
@@ -159,6 +159,7 @@ async fn summarize_context_three_requests_and_instructions() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello world".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -180,6 +181,7 @@ async fn summarize_context_three_requests_and_instructions() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: THIRD_USER_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -573,6 +575,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: user_message.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1049,6 +1052,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: FIRST_AUTO_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1061,6 +1065,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: SECOND_AUTO_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1073,6 +1078,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: POST_AUTO_USER_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1273,6 +1279,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: follow_up_user.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: resumed.cwd.path().to_path_buf(),
@@ -1382,6 +1389,7 @@ async fn auto_compact_persists_rollout_entries() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: FIRST_AUTO_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1393,6 +1401,7 @@ async fn auto_compact_persists_rollout_entries() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: SECOND_AUTO_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1404,6 +1413,7 @@ async fn auto_compact_persists_rollout_entries() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: POST_AUTO_USER_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1496,6 +1506,7 @@ async fn manual_compact_retries_after_context_window_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "first turn".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1629,6 +1640,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: first_user_message.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1643,6 +1655,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: second_user_message.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1657,6 +1670,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: final_user_message.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1835,7 +1849,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
     for user in [MULTI_AUTO_MSG, follow_up_user, final_user] {
         codex
             .submit(Op::UserInput {
-                items: vec![UserInput::Text { text: user.into() }],
+                items: vec![UserInput::Text {
+                    text: user.into(),
+                    text_elements: Vec::new(),
+                }],
                 final_output_json_schema: None,
             })
             .await
@@ -1948,6 +1965,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: FUNCTION_CALL_LIMIT_MSG.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1960,6 +1978,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: follow_up_user.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -2075,7 +2094,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
     {
         codex
             .submit(Op::UserInput {
-                items: vec![UserInput::Text { text: user.into() }],
+                items: vec![UserInput::Text {
+                    text: user.into(),
+                    text_elements: Vec::new(),
+                }],
                 final_output_json_schema: None,
             })
             .await
diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs
index 1c598a9ab6ed..a18baab540a1 100644
--- a/codex-rs/core/tests/suite/compact_remote.rs
+++ b/codex-rs/core/tests/suite/compact_remote.rs
@@ -73,6 +73,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello remote compact".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -86,6 +87,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "after compact".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -192,6 +194,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello remote compact".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -265,6 +268,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "needs compaction".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs
index a8de9e5c1527..e054908aec03 100644
--- a/codex-rs/core/tests/suite/compact_resume_fork.rs
+++ b/codex-rs/core/tests/suite/compact_resume_fork.rs
@@ -983,7 +983,10 @@ async fn start_test_conversation(
 async fn user_turn(conversation: &Arc<CodexThread>, text: &str) {
     conversation
         .submit(Op::UserInput {
-            items: vec![UserInput::Text { text: text.into() }],
+            items: vec![UserInput::Text {
+                text: text.into(),
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
         })
         .await
diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs
index 48471e457986..c7ae447bd0ff 100644
--- a/codex-rs/core/tests/suite/exec_policy.rs
+++ b/codex-rs/core/tests/suite/exec_policy.rs
@@ -72,6 +72,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run shell command".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: test.cwd_path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs
index e2e6535752c3..2a9f604c3d24 100644
--- a/codex-rs/core/tests/suite/fork_thread.rs
+++ b/codex-rs/core/tests/suite/fork_thread.rs
@@ -70,6 +70,7 @@ async fn fork_thread_twice_drops_to_first_message() {
             .submit(Op::UserInput {
                 items: vec![UserInput::Text {
                     text: text.to_string(),
+                    text_elements: Vec::new(),
                 }],
                 final_output_json_schema: None,
             })
diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs
index 401c80912998..0df7c7e73140 100644
--- a/codex-rs/core/tests/suite/image_rollout.rs
+++ b/codex-rs/core/tests/suite/image_rollout.rs
@@ -117,6 +117,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
                 },
                 UserInput::Text {
                     text: "pasted image".to_string(),
+                    text_elements: Vec::new(),
                 },
             ],
             final_output_json_schema: None,
@@ -194,6 +195,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
                 },
                 UserInput::Text {
                     text: "dropped image".to_string(),
+                    text_elements: Vec::new(),
                 },
             ],
             final_output_json_schema: None,
diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs
index 3c6cf5ff3efa..ce9b0f13407a 100644
--- a/codex-rs/core/tests/suite/items.rs
+++ b/codex-rs/core/tests/suite/items.rs
@@ -42,6 +42,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: (vec![UserInput::Text {
                 text: "please inspect sample.txt".into(),
+                text_elements: Vec::new(),
             }]),
             final_output_json_schema: None,
         })
@@ -69,12 +70,14 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
         started_item.content,
         vec![UserInput::Text {
             text: "please inspect sample.txt".into(),
+            text_elements: Vec::new(),
         }]
     );
     assert_eq!(
         completed_item.content,
         vec![UserInput::Text {
             text: "please inspect sample.txt".into(),
+            text_elements: Vec::new(),
         }]
     );
     Ok(())
@@ -99,6 +102,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "please summarize results".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -156,6 +160,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "explain your reasoning".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -215,6 +220,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "find the weather".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -268,6 +274,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "please stream text".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -334,6 +341,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "reason through it".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -392,6 +400,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "show raw reasoning".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs
index 1b9949102e69..d90cb217c8db 100644
--- a/codex-rs/core/tests/suite/json_result.rs
+++ b/codex-rs/core/tests/suite/json_result.rs
@@ -76,6 +76,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello world".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: Some(serde_json::from_str(SCHEMA)?),
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs
index af8c853159f5..706a3dea55ad 100644
--- a/codex-rs/core/tests/suite/models_cache_ttl.rs
+++ b/codex-rs/core/tests/suite/models_cache_ttl.rs
@@ -87,7 +87,10 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
 
     codex
         .submit(Op::UserTurn {
-            items: vec![UserInput::Text { text: "hi".into() }],
+            items: vec![UserInput::Text {
+                text: "hi".into(),
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
             cwd: test.cwd_path().to_path_buf(),
             approval_policy: codex_core::protocol::AskForApproval::Never,
diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs
index a733800cb005..4976b2881c99 100644
--- a/codex-rs/core/tests/suite/models_etag_responses.rs
+++ b/codex-rs/core/tests/suite/models_etag_responses.rs
@@ -100,6 +100,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please run a tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs
index ed7c8fb04fc9..5138a145ba28 100644
--- a/codex-rs/core/tests/suite/otel.rs
+++ b/codex-rs/core/tests/suite/otel.rs
@@ -45,6 +45,7 @@ async fn responses_api_emits_api_request_event() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -87,6 +88,7 @@ async fn process_sse_emits_tracing_for_output_item() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -126,6 +128,7 @@ async fn process_sse_emits_failed_event_on_parse_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -166,6 +169,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -226,6 +230,7 @@ async fn process_sse_failed_event_records_response_error_message() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -284,6 +289,7 @@ async fn process_sse_failed_event_logs_parse_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -329,6 +335,7 @@ async fn process_sse_failed_event_logs_missing_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -383,6 +390,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -434,6 +442,7 @@ async fn process_sse_emits_completed_telemetry() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -502,6 +511,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -567,6 +577,7 @@ async fn record_responses_sets_span_fields_for_response_events() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -647,6 +658,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -715,6 +727,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -793,6 +806,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -855,6 +869,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -960,6 +975,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1009,6 +1025,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "approved".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1068,6 +1085,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "persist".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1127,6 +1145,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "retry".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1186,6 +1205,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "deny".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1245,6 +1265,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "persist".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -1305,6 +1326,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "deny".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs
index 3fbf79fa0c77..ad662d901bce 100644
--- a/codex-rs/core/tests/suite/pending_input.rs
+++ b/codex-rs/core/tests/suite/pending_input.rs
@@ -100,6 +100,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "first prompt".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -115,6 +116,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "second prompt".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs
index 3f8a9b490344..6f676c1da785 100644
--- a/codex-rs/core/tests/suite/permissions_messages.rs
+++ b/codex-rs/core/tests/suite/permissions_messages.rs
@@ -61,6 +61,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -93,6 +94,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -114,6 +116,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -152,6 +155,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -162,6 +166,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -203,6 +208,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -226,6 +232,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -238,6 +245,7 @@ async fn resume_replays_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "after resume".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -276,6 +284,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -299,6 +308,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -319,6 +329,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "after resume".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -346,6 +357,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "after fork".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -393,6 +405,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs
index 303e958b3c31..b3ed78f69db4 100644
--- a/codex-rs/core/tests/suite/prompt_caching.rs
+++ b/codex-rs/core/tests/suite/prompt_caching.rs
@@ -108,6 +108,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -118,6 +119,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -180,6 +182,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -190,6 +193,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -241,6 +245,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -251,6 +256,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -316,6 +322,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -345,6 +352,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -406,6 +414,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "first message".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -517,6 +526,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -536,6 +546,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             cwd: new_cwd.path().to_path_buf(),
             approval_policy: AskForApproval::Never,
@@ -627,6 +638,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             cwd: default_cwd.clone(),
             approval_policy: default_approval_policy,
@@ -643,6 +655,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             cwd: default_cwd.clone(),
             approval_policy: default_approval_policy,
@@ -720,6 +733,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello 1".into(),
+                text_elements: Vec::new(),
             }],
             cwd: default_cwd.clone(),
             approval_policy: default_approval_policy,
@@ -736,6 +750,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello 2".into(),
+                text_elements: Vec::new(),
             }],
             cwd: default_cwd.clone(),
             approval_policy: AskForApproval::Never,
diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs
index 17fc50614fec..afa23275e0da 100644
--- a/codex-rs/core/tests/suite/quota_exceeded.rs
+++ b/codex-rs/core/tests/suite/quota_exceeded.rs
@@ -43,6 +43,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "quota?".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs
index 38d010971250..75c22df554cb 100644
--- a/codex-rs/core/tests/suite/remote_models.rs
+++ b/codex-rs/core/tests/suite/remote_models.rs
@@ -165,6 +165,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run call".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -369,6 +370,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "hello remote".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs
index 50e3fd92107a..9a99b913fd8f 100644
--- a/codex-rs/core/tests/suite/request_compression.rs
+++ b/codex-rs/core/tests/suite/request_compression.rs
@@ -39,6 +39,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "compress me".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -82,6 +83,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() ->
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "do not compress".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs
index 442075a6fc0f..8424146d65b3 100644
--- a/codex-rs/core/tests/suite/resume.rs
+++ b/codex-rs/core/tests/suite/resume.rs
@@ -36,6 +36,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "Record some messages".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -89,6 +90,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "Record reasoning messages".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs
index e0870b37612c..d182af82558c 100644
--- a/codex-rs/core/tests/suite/review.rs
+++ b/codex-rs/core/tests/suite/review.rs
@@ -707,6 +707,7 @@ async fn review_history_surfaces_in_parent_session() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: followup.clone(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs
index eef1af00fea5..5001eead3234 100644
--- a/codex-rs/core/tests/suite/rmcp_client.rs
+++ b/codex-rs/core/tests/suite/rmcp_client.rs
@@ -108,6 +108,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp echo tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
@@ -244,6 +245,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp image tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
@@ -438,6 +440,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp image tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
@@ -580,6 +583,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp echo tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
@@ -733,6 +737,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp streamable http echo tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
@@ -918,6 +923,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp streamable http oauth echo tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs
index 39f2b3a33e52..74ff56e84e98 100644
--- a/codex-rs/core/tests/suite/shell_snapshot.rs
+++ b/codex-rs/core/tests/suite/shell_snapshot.rs
@@ -71,6 +71,7 @@ async fn run_snapshot_command(command: &str) -> Result<SnapshotRun> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run unified exec with shell snapshot".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd,
@@ -147,6 +148,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result<SnapshotRun> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run shell_command with shell snapshot".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd,
@@ -284,6 +286,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "apply patch via shell_command with snapshot".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.clone(),
diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs
index ee93435880c5..eb6b4288d074 100644
--- a/codex-rs/core/tests/suite/skills.rs
+++ b/codex-rs/core/tests/suite/skills.rs
@@ -64,6 +64,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
             items: vec![
                 UserInput::Text {
                     text: "please use $demo".to_string(),
+                    text_elements: Vec::new(),
                 },
                 UserInput::Skill {
                     name: "demo".to_string(),
diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs
index b17bb632096b..6bfcef38b3fa 100644
--- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs
+++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs
@@ -88,6 +88,7 @@ async fn continue_after_stream_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "first message".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
@@ -106,6 +107,7 @@ async fn continue_after_stream_error() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "follow up".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs
index 3aa20c0c735d..a4962d497ebd 100644
--- a/codex-rs/core/tests/suite/stream_no_completed.rs
+++ b/codex-rs/core/tests/suite/stream_no_completed.rs
@@ -95,6 +95,7 @@ async fn retries_on_early_close() {
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs
index e7cd43ca6787..75d707af5342 100644
--- a/codex-rs/core/tests/suite/tool_harness.rs
+++ b/codex-rs/core/tests/suite/tool_harness.rs
@@ -82,6 +82,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please run the shell command".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -148,6 +149,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please update the plan".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -224,6 +226,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please update the plan".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -312,6 +315,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please apply a patch".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -408,6 +412,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please apply a patch".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs
index 7661cb423684..4abf85d7dc11 100644
--- a/codex-rs/core/tests/suite/tool_parallelism.rs
+++ b/codex-rs/core/tests/suite/tool_parallelism.rs
@@ -38,6 +38,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: prompt.into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: test.cwd.path().to_path_buf(),
@@ -353,6 +354,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "stream delayed completion".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: test.cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs
index 80204e8e1325..a86489bdd05b 100644
--- a/codex-rs/core/tests/suite/truncation.rs
+++ b/codex-rs/core/tests/suite/truncation.rs
@@ -536,6 +536,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "call the rmcp image tool".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: fixture.cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs
index 33524c121941..3a6c728df009 100644
--- a/codex-rs/core/tests/suite/unified_exec.rs
+++ b/codex-rs/core/tests/suite/unified_exec.rs
@@ -200,6 +200,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "apply patch via unified exec".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd,
@@ -326,6 +327,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "emit begin event".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -401,6 +403,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run relative workdir test".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -479,6 +482,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run workdir test".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -569,6 +573,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "emit end event".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -641,6 +646,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "emit delta".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -714,6 +720,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "exercise full unified exec lifecycle".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -840,6 +847,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()>
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "stdin delta".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -973,6 +981,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<(
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "delayed terminal interaction output".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1130,6 +1139,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "check poll event behavior".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1225,6 +1235,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run metadata test".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1340,6 +1351,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "check default pipe mode".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1427,6 +1439,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "check tty enabled".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1505,6 +1518,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "watch early exit timing".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1633,6 +1647,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "test write_stdin exit behavior".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1798,6 +1813,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "end on exit".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1872,6 +1888,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "close unified exec processes on turn end".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -1990,6 +2007,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "run unified exec".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2122,6 +2140,7 @@ PY
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "exercise lag handling".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2233,6 +2252,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "check timeout".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2326,6 +2346,7 @@ PY
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "summarize large output".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2404,6 +2425,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "summarize large output".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2506,6 +2528,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "start python under seatbelt".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2598,6 +2621,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "summarize large output".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -2728,6 +2752,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "fill session cache".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs
index 061725823341..69f30bb5add9 100644
--- a/codex-rs/core/tests/suite/user_notification.rs
+++ b/codex-rs/core/tests/suite/user_notification.rs
@@ -60,6 +60,7 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
         .submit(Op::UserInput {
             items: vec![UserInput::Text {
                 text: "hello world".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         })
diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs
index 034254f7c597..ebbbfc478e93 100644
--- a/codex-rs/core/tests/suite/view_image.rs
+++ b/codex-rs/core/tests/suite/view_image.rs
@@ -171,6 +171,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please add the screenshot".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -301,6 +302,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please attach the folder".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -373,6 +375,7 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please use the view_image tool to read the json file".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
@@ -464,6 +467,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
         .submit(Op::UserTurn {
             items: vec![UserInput::Text {
                 text: "please attach the missing image".into(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
             cwd: cwd.path().to_path_buf(),
diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs
index 344dd2020be0..5267e4815622 100644
--- a/codex-rs/debug-client/src/client.rs
+++ b/codex-rs/debug-client/src/client.rs
@@ -184,7 +184,11 @@ impl AppServerClient {
             request_id: request_id.clone(),
             params: TurnStartParams {
                 thread_id: thread_id.to_string(),
-                input: vec![UserInput::Text { text }],
+                input: vec![UserInput::Text {
+                    text,
+                    // Plain text conversion has no UI element ranges.
+                    text_elements: Vec::new(),
+                }],
                 ..Default::default()
             },
         };
diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs
index 13baedc2253c..131e36ce22b3 100644
--- a/codex-rs/exec/src/lib.rs
+++ b/codex-rs/exec/src/lib.rs
@@ -360,6 +360,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
                 .collect();
             items.push(UserInput::Text {
                 text: prompt_text.clone(),
+                text_elements: Vec::new(),
             });
             let output_schema = load_output_schema(output_schema_path.clone());
             (
@@ -378,6 +379,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
                 .collect();
             items.push(UserInput::Text {
                 text: prompt_text.clone(),
+                text_elements: Vec::new(),
             });
             let output_schema = load_output_schema(output_schema_path);
             (
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index 4d826f920f43..6aafdf6de9eb 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -116,6 +116,7 @@ pub async fn run_codex_tool_session(
         op: Op::UserInput {
             items: vec![UserInput::Text {
                 text: initial_prompt.clone(),
+                text_elements: Vec::new(),
             }],
             final_output_json_schema: None,
         },
@@ -158,7 +159,11 @@ pub async fn run_codex_tool_session_reply(
         .insert(request_id.clone(), thread_id);
     if let Err(e) = thread
         .submit(Op::UserInput {
-            items: vec![UserInput::Text { text: prompt }],
+            items: vec![UserInput::Text {
+                text: prompt,
+                // Plain text conversion has no UI element ranges.
+                text_elements: Vec::new(),
+            }],
             final_output_json_schema: None,
         })
         .await
diff --git a/codex-rs/otel/src/traces/otel_manager.rs b/codex-rs/otel/src/traces/otel_manager.rs
index 44d2416ac094..2b490bbb3760 100644
--- a/codex-rs/otel/src/traces/otel_manager.rs
+++ b/codex-rs/otel/src/traces/otel_manager.rs
@@ -324,7 +324,7 @@ impl OtelManager {
         let prompt = items
             .iter()
             .flat_map(|item| match item {
-                UserInput::Text { text } => Some(text.as_str()),
+                UserInput::Text { text, .. } => Some(text.as_str()),
                 _ => None,
             })
             .collect::<String>();
diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs
index 36ee0be07ce5..9d6b484d920e 100644
--- a/codex-rs/protocol/src/items.rs
+++ b/codex-rs/protocol/src/items.rs
@@ -65,6 +65,10 @@ impl UserMessageItem {
         EventMsg::UserMessage(UserMessageEvent {
             message: self.message(),
             images: Some(self.image_urls()),
+            // TODO: Thread text element ranges into legacy user message events.
+            text_elements: Vec::new(),
+            // TODO: Thread local image paths into legacy user message events.
+            local_images: Vec::new(),
         })
     }
 
@@ -72,7 +76,7 @@ impl UserMessageItem {
         self.content
             .iter()
             .map(|c| match c {
-                UserInput::Text { text } => text.clone(),
+                UserInput::Text { text, .. } => text.clone(),
                 _ => String::new(),
             })
             .collect::<Vec<String>>()
diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs
index 04f54b1dcc58..d2c9ddd564d4 100644
--- a/codex-rs/protocol/src/models.rs
+++ b/codex-rs/protocol/src/models.rs
@@ -550,7 +550,7 @@ impl From<Vec<UserInput>> for ResponseInputItem {
             content: items
                 .into_iter()
                 .flat_map(|c| match c {
-                    UserInput::Text { text } => vec![ContentItem::InputText { text }],
+                    UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
                     UserInput::Image { image_url } => vec![
                         ContentItem::InputText {
                             text: image_open_tag_text(),
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 2116813e1a7d..6850c2ac1159 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -1255,8 +1255,19 @@ pub struct AgentMessageEvent {
 #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
 pub struct UserMessageEvent {
     pub message: String,
+    /// Image URLs sourced from `UserInput::Image`. These are safe
+    /// to replay in legacy UI history events and correspond to images sent to
+    /// the model.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub images: Option<Vec<String>>,
+    /// Local file paths sourced from `UserInput::LocalImage`. These are kept so
+    /// the UI can reattach images when editing history, and should not be sent
+    /// to the model or treated as API-ready URLs.
+    #[serde(default)]
+    pub local_images: Vec<std::path::PathBuf>,
+    /// UI-defined spans within `message` used to render or persist special elements.
+    #[serde(default)]
+    pub text_elements: Vec<crate::user_input::TextElement>,
 }
 
 #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -2298,6 +2309,48 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn user_input_text_serializes_empty_text_elements() -> Result<()> {
+        let input = UserInput::Text {
+            text: "hello".to_string(),
+            text_elements: Vec::new(),
+        };
+
+        let json_input = serde_json::to_value(input)?;
+        assert_eq!(
+            json_input,
+            json!({
+                "type": "text",
+                "text": "hello",
+                "text_elements": [],
+            })
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> {
+        let event = UserMessageEvent {
+            message: "hello".to_string(),
+            images: None,
+            local_images: Vec::new(),
+            text_elements: Vec::new(),
+        };
+
+        let json_event = serde_json::to_value(event)?;
+        assert_eq!(
+            json_event,
+            json!({
+                "message": "hello",
+                "local_images": [],
+                "text_elements": [],
+            })
+        );
+
+        Ok(())
+    }
+
     /// Serialize Event to verify that its JSON representation has the expected
     /// amount of nesting.
     #[test]
diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs
index 26773e1a1a89..b9ee693d8521 100644
--- a/codex-rs/protocol/src/user_input.rs
+++ b/codex-rs/protocol/src/user_input.rs
@@ -10,17 +10,19 @@ use ts_rs::TS;
 pub enum UserInput {
     Text {
         text: String,
+        /// UI-defined spans within `text` that should be treated as special elements.
+        /// These are byte ranges into the UTF-8 `text` buffer and are used to render
+        /// or persist rich input markers (e.g., image placeholders) across history
+        /// and resume without mutating the literal text.
+        #[serde(default)]
+        text_elements: Vec<TextElement>,
     },
     /// Pre‑encoded data: URI image.
-    Image {
-        image_url: String,
-    },
+    Image { image_url: String },
 
     /// Local image path provided by the user.  This will be converted to an
     /// `Image` variant (base64 data URL) during request serialization.
-    LocalImage {
-        path: std::path::PathBuf,
-    },
+    LocalImage { path: std::path::PathBuf },
 
     /// Skill selected by the user (name + path to SKILL.md).
     Skill {
@@ -28,3 +30,28 @@ pub enum UserInput {
         path: std::path::PathBuf,
     },
 }
+
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)]
+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<String>,
+}
+
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)]
+pub struct ByteRange {
+    /// Start byte offset (inclusive) within the UTF-8 text buffer.
+    pub start: usize,
+    /// End byte offset (exclusive) within the UTF-8 text buffer.
+    pub end: usize,
+}
+
+impl From<std::ops::Range<usize>> for ByteRange {
+    fn from(range: std::ops::Range<usize>) -> Self {
+        Self {
+            start: range.start,
+            end: range.end,
+        }
+    }
+}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 9050b4758ce5..6d9f9dafa678 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -2266,7 +2266,11 @@ impl ChatWidget {
         }
 
         if !text.is_empty() {
-            items.push(UserInput::Text { text: text.clone() });
+            // TODO: Thread text element ranges from the composer input. Empty keeps old behavior.
+            items.push(UserInput::Text {
+                text: text.clone(),
+                text_elements: Vec::new(),
+            });
         }
 
         if let Some(skills) = self.bottom_pane.skills() {
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 78000adff280..2d9d808ed1d2 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -143,6 +143,8 @@ async fn resumed_initial_messages_render_history() {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "hello from user".to_string(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "assistant reply".to_string(),
diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs
index c103e058a00a..1e38e4483527 100644
--- a/codex-rs/tui2/src/chatwidget.rs
+++ b/codex-rs/tui2/src/chatwidget.rs
@@ -2017,7 +2017,11 @@ impl ChatWidget {
         }
 
         if !text.is_empty() {
-            items.push(UserInput::Text { text: text.clone() });
+            // TODO: Thread text element ranges from the composer input. Empty keeps old behavior.
+            items.push(UserInput::Text {
+                text: text.clone(),
+                text_elements: Vec::new(),
+            });
         }
 
         if let Some(skills) = self.bottom_pane.skills() {
diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs
index 82cf4b0ea82c..f6cb457bf2e4 100644
--- a/codex-rs/tui2/src/chatwidget/tests.rs
+++ b/codex-rs/tui2/src/chatwidget/tests.rs
@@ -132,6 +132,8 @@ async fn resumed_initial_messages_render_history() {
             EventMsg::UserMessage(UserMessageEvent {
                 message: "hello from user".to_string(),
                 images: None,
+                text_elements: Vec::new(),
+                local_images: Vec::new(),
             }),
             EventMsg::AgentMessage(AgentMessageEvent {
                 message: "assistant reply".to_string(),
