diff --git a/codex-rs/tui/src/app/history_pagination_tests.rs b/codex-rs/tui/src/app/history_pagination_tests.rs index 69e996d6de..949592d2f6 100644 --- a/codex-rs/tui/src/app/history_pagination_tests.rs +++ b/codex-rs/tui/src/app/history_pagination_tests.rs @@ -55,7 +55,7 @@ fn overlapping_history_keeps_live_turn_state_and_newer_items() { fn user_cell(message: &str) -> Arc { Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: message.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 8cbd56e3a2..c3ec487f6d 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -25,7 +25,7 @@ use ratatui::buffer::Buffer; pub(in crate::app) fn user_cell(message: &str) -> Arc { Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: message.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/app/recap_tests.rs b/codex-rs/tui/src/app/recap_tests.rs index 8990301727..6b8a30a864 100644 --- a/codex-rs/tui/src/app/recap_tests.rs +++ b/codex-rs/tui/src/app/recap_tests.rs @@ -59,7 +59,7 @@ fn turn(status: TurnStatus) -> Turn { fn user_history_cell(message: &str) -> Arc { Arc::new(UserHistoryCell { message: message.to_string(), - spoken: false, + presentation: Default::default(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 9e5b8315da..87f5bceb68 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6226,7 +6226,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: text.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -7953,7 +7953,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { remote_image_urls: Vec| -> Arc { Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: text.to_string(), text_elements, local_image_paths, @@ -10164,7 +10164,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.chat_widget .apply_external_edit("draft prompt".to_string()); app.transcript_cells = vec![Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "old message".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs index 7d467c9389..847cd0258d 100644 --- a/codex-rs/tui/src/app/tests/recap_generation_tests.rs +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -40,7 +40,7 @@ async fn prepare_eligible_recap(app: &mut App, thread_id: ThreadId) { app.transcript_cells .push(Arc::new(crate::history_cell::UserHistoryCell { message: "Finish the recap implementation".to_string(), - spoken: false, + presentation: Default::default(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), @@ -130,7 +130,7 @@ stream_max_retries = 0 app.transcript_cells .push(Arc::new(crate::history_cell::UserHistoryCell { message: "Keep follow-up work queued.".to_string(), - spoken: false, + presentation: Default::default(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), @@ -236,7 +236,7 @@ async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> { app.transcript_cells .push(Arc::new(crate::history_cell::UserHistoryCell { message: "Summarize this conversation".to_string(), - spoken: false, + presentation: Default::default(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), @@ -455,7 +455,7 @@ async fn recap_generation_uses_remote_workspace_cwd() -> Result<()> { app.transcript_cells .push(Arc::new(crate::history_cell::UserHistoryCell { message: "Finish the recap implementation".to_string(), - spoken: false, + presentation: Default::default(), text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), diff --git a/codex-rs/tui/src/app/tests/startup_warnings_tests.rs b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs index ef752b8a55..8cd33f21d2 100644 --- a/codex-rs/tui/src/app/tests/startup_warnings_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs @@ -63,7 +63,7 @@ async fn deprecation_delivery_deduplicates_retained_transcript() -> Result<()> { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), - spoken: false, + presentation: Default::default(), }), ); app.open_transcript_overlay(&mut tui); @@ -148,7 +148,7 @@ async fn startup_warnings_preserve_stream_repair_and_backtrack_selection() -> Re text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), - spoken: false, + presentation: Default::default(), })]; app.transcript_reflow.mark_resize_requested_during_stream(); insert_warnings(&mut app, &mut tui, &["First warning"]); diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs index 7fe8788585..b1e9e6635d 100644 --- a/codex-rs/tui/src/app/tests/transcript_composer.rs +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -237,7 +237,7 @@ async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), - spoken: false, + presentation: Default::default(), }) as Arc }) .to_vec(); diff --git a/codex-rs/tui/src/app/tests/transcript_selection.rs b/codex-rs/tui/src/app/tests/transcript_selection.rs index 29f2782ef3..75462d38ed 100644 --- a/codex-rs/tui/src/app/tests/transcript_selection.rs +++ b/codex-rs/tui/src/app/tests/transcript_selection.rs @@ -18,7 +18,7 @@ async fn text_selection_temporarily_replaces_the_rendered_backtrack_highlight() text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), - spoken: false, + presentation: Default::default(), })]; app.open_transcript_overlay(&mut tui); while events.try_recv().is_ok() {} diff --git a/codex-rs/tui/src/app/transcript_export_tests.rs b/codex-rs/tui/src/app/transcript_export_tests.rs index 15d1555b15..83476dc5d9 100644 --- a/codex-rs/tui/src/app/transcript_export_tests.rs +++ b/codex-rs/tui/src/app/transcript_export_tests.rs @@ -22,7 +22,7 @@ fn markdown_transcript_preserves_messages_and_formats_activity() { assert!(render_markdown_transcript(&[]).is_err()); let user = |message: &str, local_image_paths| { Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: message.to_string(), text_elements: Vec::new(), local_image_paths, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index c6f3119641..227f8f11bf 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1215,7 +1215,7 @@ mod tests { assert!(!has_backtrack_target(&cells)); cells.push(Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "hello".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 1cd4a356c5..99b41b1b39 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -18,7 +18,14 @@ pub(crate) struct UserHistoryCell { pub text_elements: Vec, pub local_image_paths: Vec, pub remote_image_urls: Vec, - pub(crate) spoken: bool, + pub(crate) presentation: UserMessagePresentation, +} + +#[derive(Debug, Default)] +pub(crate) enum UserMessagePresentation { + #[default] + Plain, + Spoken, } /// Remove CSI sequences and control characters, preserving tabs and newlines. @@ -152,6 +159,10 @@ fn build_user_message_lines_with_elements( } impl UserHistoryCell { + pub(crate) fn is_spoken(&self) -> bool { + matches!(self.presentation, UserMessagePresentation::Spoken) + } + pub(crate) fn has_visible_content(&self) -> bool { !sanitize_user_text(Cow::Borrowed(&self.message)) .trim() @@ -183,7 +194,7 @@ impl HistoryCell for UserHistoryCell { fn display_hyperlink_lines(&self, width: u16) -> Vec { let sanitized_message = sanitize_user_text((&self.message).into()); // Speech transcripts can begin with whitespace; the marker already supplies its separator. - let message = if self.spoken { + let message = if self.is_spoken() { sanitized_message.trim_start() } else { sanitized_message.as_ref() @@ -231,7 +242,7 @@ impl HistoryCell for UserHistoryCell { if let Some(wrapped_message) = wrapped_message { lines.extend(prefix_hyperlink_lines( wrapped_message, - if self.spoken { + if self.is_spoken() { "› ".red().bold() } else { "› ".bold().dim() @@ -730,13 +741,13 @@ pub(crate) fn new_user_prompt( text_elements, local_image_paths, remote_image_urls, - spoken: false, + presentation: UserMessagePresentation::Plain, } } pub(crate) fn new_spoken_user_prompt(message: String) -> UserHistoryCell { let mut cell = new_user_prompt(message, Vec::new(), Vec::new(), Vec::new()); - cell.spoken = true; + cell.presentation = UserMessagePresentation::Spoken; cell } diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 8996d8e3a1..caf701e25e 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -2311,7 +2311,7 @@ fn ran_cell_multiline_with_stderr_snapshot() { fn user_history_cell_wraps_and_prefixes_each_line_snapshot() { let msg = "_count_r\x1b[13;2:3uows"; let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: msg.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2335,7 +2335,7 @@ fn user_history_cell_wraps_long_urls_inside_the_message_gutter() { ); let image_start = message.find("[Image #1]").unwrap(); let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message, text_elements: vec![TextElement::new( (image_start..image_start + "[Image #1]".len()).into(), @@ -2386,7 +2386,7 @@ fn user_history_cell_wraps_long_urls_inside_the_message_gutter() { #[test] fn user_history_cell_renders_remote_image_urls() { let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "describe these".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2407,7 +2407,7 @@ fn user_image_labels_follow_the_painted_prompt_surface() { let placeholder = "[Image #1]"; let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: format!("{placeholder} describe these"), text_elements: vec![TextElement::new( (0..placeholder.len()).into(), @@ -2443,7 +2443,7 @@ fn user_image_labels_follow_the_painted_prompt_surface() { #[test] fn user_history_cell_summarizes_inline_data_urls() { let mut cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "describe inline image".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2469,7 +2469,7 @@ fn user_history_cell_summarizes_inline_data_urls() { #[test] fn user_history_cell_numbers_multiple_remote_images() { let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "describe both".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2489,7 +2489,7 @@ fn user_history_cell_numbers_multiple_remote_images() { #[test] fn user_history_cell_height_matches_rendered_lines_with_remote_images() { let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "line one\nline two".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2512,7 +2512,7 @@ fn user_history_cell_height_matches_rendered_lines_with_remote_images() { #[test] fn user_history_cell_trims_trailing_blank_message_lines() { let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "line one\n\n \n\t \n".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -2533,7 +2533,7 @@ fn user_history_cell_trims_trailing_blank_message_lines() { fn user_history_cell_trims_trailing_blank_message_lines_with_text_elements() { let message = "tokenized\n\n\n".to_string(); let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message, text_elements: vec![TextElement::new( (0..8).into(), @@ -2557,7 +2557,7 @@ fn user_history_cell_trims_trailing_blank_message_lines_with_text_elements() { fn render_uses_wrapping_for_long_url_like_line() { let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes-only-and-does/not/need/to/resolve/index.html?session_id=abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"; let cell: Box = Box::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: url.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -3049,7 +3049,7 @@ fn agent_markdown_cell_narrow_width_shows_prefix_only() { #[test] fn wrapped_and_prefixed_cells_handle_tiny_widths() { let user_cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "tiny width coverage for wrapped user history".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), @@ -3156,7 +3156,7 @@ fn consolidation_walker_replaces_agent_message_cells() { // Build a transcript with: [UserCell, AgentMsg(head), AgentMsg(cont), AgentMsg(cont)] let user = Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: "hello".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index afa649f70a..ffd73634f3 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -834,7 +834,7 @@ mod tests { let url = "https://example.test/forwarded/threads/10930?page=1&queue=customer_support_unprocessed&forwardedScope=all"; let cell = UserHistoryCell { - spoken: false, + presentation: Default::default(), message: url.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 9327c77e77..15f573a1b1 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -259,7 +259,7 @@ fn item_to_cells( item.text_elements() }; cells.push(Arc::new(UserHistoryCell { - spoken: false, + presentation: Default::default(), message: reply_text.unwrap_or(message), text_elements, local_image_paths: item.local_image_paths(), diff --git a/codex-rs/tui/src/transcript_view/prompt_header.rs b/codex-rs/tui/src/transcript_view/prompt_header.rs index b427277761..fe7d17fa8e 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header.rs @@ -53,7 +53,7 @@ pub(super) fn line( Some(( truncate_line_with_ellipsis_if_overflow( Line::from(vec![ - if prompt.spoken { + if prompt.is_spoken() { "› ".red().bold() } else { "› ".bold().dim()