Represent user message presentation with an enum (#57291)

## What changed

Replace `UserHistoryCell`'s `spoken` boolean with `UserMessagePresentation`, defaulting to `Plain` and using `Spoken` for speech transcripts. Route rendering checks through `is_spoken()`, preserving the existing prompt styling and whitespace handling.

GitOrigin-RevId: d0683a2b536a7d71f68fd9a089e6839d0ab0cc65
This commit is contained in:
Ian MacLeod
2026-10-26 22:11:27 +00:00
committed by copyberry
parent 16effdfc8c
commit a014908c50
15 changed files with 47 additions and 36 deletions
@@ -55,7 +55,7 @@ fn overlapping_history_keeps_live_turn_state_and_newer_items() {
fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
spoken: false,
presentation: Default::default(),
message: message.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
@@ -25,7 +25,7 @@ use ratatui::buffer::Buffer;
pub(in crate::app) fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
spoken: false,
presentation: Default::default(),
message: message.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
+1 -1
View File
@@ -59,7 +59,7 @@ fn turn(status: TurnStatus) -> Turn {
fn user_history_cell(message: &str) -> Arc<dyn HistoryCell> {
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(),
+3 -3
View File
@@ -6226,7 +6226,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
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<String>|
-> Arc<dyn HistoryCell> {
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(),
@@ -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(),
@@ -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"]);
@@ -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<dyn HistoryCell>
})
.to_vec();
@@ -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() {}
@@ -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,
+1 -1
View File
@@ -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(),
+16 -5
View File
@@ -18,7 +18,14 @@ pub(crate) struct UserHistoryCell {
pub text_elements: Vec<TextElement>,
pub local_image_paths: Vec<PathBuf>,
pub remote_image_urls: Vec<String>,
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<HyperlinkLine> {
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
}
+12 -12
View File
@@ -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<dyn HistoryCell> = 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(),
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
@@ -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()